diff --git a/apps/backend/src/app/api/latest/oauth-providers/crud.tsx b/apps/backend/src/app/api/latest/oauth-providers/crud.tsx index 591355def0..d35e1ad583 100644 --- a/apps/backend/src/app/api/latest/oauth-providers/crud.tsx +++ b/apps/backend/src/app/api/latest/oauth-providers/crud.tsx @@ -154,6 +154,7 @@ export const oauthProviderCrudHandlers = createLazyProxy(() => createCrudHandler user_id: params.user_id, id: oauthAccount.id, email: oauthAccount.email || undefined, + provider_config_id: oauthAccount.configOAuthProviderId, type: providerConfig.type as any, // Type assertion to match schema allow_sign_in: oauthAccount.allowSignIn, allow_connected_accounts: oauthAccount.allowConnectedAccounts, @@ -193,6 +194,7 @@ export const oauthProviderCrudHandlers = createLazyProxy(() => createCrudHandler user_id: oauthAccount.projectUserId || throwErr("OAuth account has no project user ID"), id: oauthAccount.id, email: oauthAccount.email || undefined, + provider_config_id: oauthAccount.configOAuthProviderId, type: providerConfig.type as any, // Type assertion to match schema allow_sign_in: oauthAccount.allowSignIn, allow_connected_accounts: oauthAccount.allowConnectedAccounts, @@ -303,6 +305,7 @@ export const oauthProviderCrudHandlers = createLazyProxy(() => createCrudHandler user_id: params.user_id, id: params.provider_id, email: data.email ?? existingOAuthAccount.email ?? undefined, + provider_config_id: existingOAuthAccount.configOAuthProviderId, type: providerConfig.type as any, allow_sign_in: data.allow_sign_in ?? existingOAuthAccount.allowSignIn, allow_connected_accounts: data.allow_connected_accounts ?? existingOAuthAccount.allowConnectedAccounts, @@ -398,6 +401,7 @@ export const oauthProviderCrudHandlers = createLazyProxy(() => createCrudHandler user_id: data.user_id, email: data.email, id: created.id, + provider_config_id: data.provider_config_id, type: providerConfig.type as any, allow_sign_in: data.allow_sign_in, allow_connected_accounts: data.allow_connected_accounts, diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/[userId]/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/[userId]/page-client.tsx index 9742a5b68c..26ec30f01b 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/[userId]/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/[userId]/page-client.tsx @@ -6,13 +6,41 @@ import { SettingCard } from "@/components/settings"; import { DeleteUserDialog, ImpersonateUserDialog } from "@/components/user-dialogs"; import { useThemeWatcher } from '@/lib/theme'; import MonacoEditor from '@monaco-editor/react'; -import { ServerContactChannel, ServerUser } from "@stackframe/stack"; +import { ServerContactChannel, ServerOAuthProvider, ServerUser } from "@stackframe/stack"; +import { KnownErrors } from "@stackframe/stack-shared"; import { useAsyncCallback } from "@stackframe/stack-shared/dist/hooks/use-async-callback"; import { fromNow } from "@stackframe/stack-shared/dist/utils/dates"; -import { throwErr } from '@stackframe/stack-shared/dist/utils/errors'; +import { StackAssertionError, throwErr } from '@stackframe/stack-shared/dist/utils/errors'; import { isJsonSerializable } from "@stackframe/stack-shared/dist/utils/json"; import { deindent } from "@stackframe/stack-shared/dist/utils/strings"; -import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, ActionCell, Avatar, AvatarFallback, AvatarImage, Button, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, Input, Separator, SimpleTooltip, Table, TableBody, TableCell, TableHead, TableHeader, TableRow, Typography, cn } from "@stackframe/stack-ui"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, + ActionCell, + Avatar, + AvatarFallback, + AvatarImage, + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, + Input, + Separator, + SimpleTooltip, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, + Typography, + cn, + useToast +} from "@stackframe/stack-ui"; import { AtSign, Calendar, Check, Hash, Mail, MoreHorizontal, Shield, SquareAsterisk, X } from "lucide-react"; import { useEffect, useMemo, useRef, useState } from "react"; import * as yup from "yup"; @@ -824,6 +852,333 @@ function ContactChannelsSection({ user }: ContactChannelsSectionProps) { ); } +type OAuthProvidersSectionProps = { + user: ServerUser, +}; + +type OAuthProviderDialogProps = { + user: ServerUser, + open: boolean, + onOpenChange: (open: boolean) => void, +} & ({ + mode: 'create', +} | { + mode: 'edit', + provider: ServerOAuthProvider, +}); + +function OAuthProviderDialog(props: OAuthProviderDialogProps) { + const stackAdminApp = useAdminApp(); + const project = stackAdminApp.useProject(); + const { toast } = useToast(); + + // Get available OAuth providers from project config + const availableProviders = project.config.oauthProviders; + const isEditMode = props.mode === 'edit'; + const provider = isEditMode ? props.provider : null; + + const formSchema = yup.object({ + providerId: yup.string() + .defined("Provider is required") + .nonEmpty("Provider is required") + .label("OAuth Provider") + .meta({ + stackFormFieldRender: (innerProps: { control: any, name: string, label: string, disabled: boolean }) => ( + ({ + value: p.id, + label: p.id.charAt(0).toUpperCase() + p.id.slice(1) + }))} + placeholder="Select OAuth provider" + /> + ), + }), + email: yup.string() + .email("Please enter a valid e-mail address") + .optional() + .label("Email") + .meta({ + stackFormFieldPlaceholder: "Enter email address (optional)", + }), + accountId: yup.string() + .defined("Account ID is required") + .label("Account ID") + .meta({ + stackFormFieldPlaceholder: "Enter OAuth account ID", + description: "The unique account identifier from the OAuth provider", + stackFormFieldExtraProps: { + disabled: isEditMode, // Disable account ID editing in edit mode + }, + }), + allowSignIn: yup.boolean() + .default(true) + .label("Used for sign-in") + .meta({ + description: "Allow this OAuth provider to be used for authentication" + }), + allowConnectedAccounts: yup.boolean() + .default(true) + .label("Used for connected accounts") + .meta({ + description: "Allow this OAuth provider to be used for connected account features" + }), + }); + + // Set default values based on mode + const defaultValues = isEditMode && provider ? { + providerId: provider.type, + email: provider.email, + accountId: provider.accountId, + allowSignIn: provider.allowSignIn, + allowConnectedAccounts: provider.allowConnectedAccounts, + } : { + providerId: "", + email: "", + accountId: "", + allowSignIn: true, + allowConnectedAccounts: true, + }; + + const handleSubmit = async (values: yup.InferType) => { + let result; + + if (isEditMode && provider) { + // Update existing provider + result = await provider.update({ + email: values.email?.trim() || provider.email, + allowSignIn: values.allowSignIn, + allowConnectedAccounts: values.allowConnectedAccounts, + }); + } else { + // Create new provider + if (!values.accountId.trim()) return; + + const providerConfig = availableProviders.find((p: any) => p.id === values.providerId); + if (!providerConfig) { + throw new StackAssertionError(`Provider config not found for ${values.providerId}`); + } + + result = await stackAdminApp.createOAuthProvider({ + userId: props.user.id, + providerConfigId: providerConfig.id, + accountId: values.accountId.trim(), + email: values.email?.trim() || "", + allowSignIn: values.allowSignIn, + allowConnectedAccounts: values.allowConnectedAccounts, + }); + } + + // Handle errors for both create and update operations + if (result.status === "error") { + const providerType = isEditMode && provider ? provider.type : values.providerId; + const accountId = isEditMode && provider ? provider.accountId : values.accountId; + const operation = isEditMode ? "updating" : "adding"; + + if (KnownErrors.OAuthProviderAccountIdAlreadyUsedForSignIn.isInstance(result.error)) { + toast({ + title: "Account Already Connected", + description: `A ${providerType} provider with account ID "${accountId}" already exists (possibly for a different user)`, + variant: "destructive", + }); + } else { + console.error(result.error); + toast({ + title: "Error", + description: `An unexpected error occurred while ${operation} the OAuth provider.`, + variant: "destructive", + }); + } + return 'prevent-close'; + } + }; + + // Update the form schema defaults based on mode + const schemaWithDefaults = formSchema.default(defaultValues); + + return ( + + ); +} + +function OAuthProvidersSection({ user }: OAuthProvidersSectionProps) { + const oauthProviders = user.useOAuthProviders(); + const [isAddProviderDialogOpen, setIsAddProviderDialogOpen] = useState(false); + const [editingProvider, setEditingProvider] = useState(null); + const { toast } = useToast(); + + const handleProviderUpdate = async (provider: ServerOAuthProvider, updates: { allowSignIn?: boolean, allowConnectedAccounts?: boolean }) => { + const result = await provider.update(updates); + if (result.status === "error") { + if (KnownErrors.OAuthProviderAccountIdAlreadyUsedForSignIn.isInstance(result.error)) { + toast({ + title: "Account Already Connected", + description: `A ${provider.type} provider with account ID "${provider.accountId}" is already connected for this user.`, + variant: "destructive", + }); + } else { + const settingType = updates.allowSignIn !== undefined ? "sign-in" : "connected accounts"; + toast({ + title: "Error", + description: `Failed to update ${settingType} setting.`, + variant: "destructive", + }); + } + } else { + let successMessage = ""; + if (updates.allowSignIn !== undefined) { + successMessage = `Sign-in ${provider.allowSignIn ? 'disabled' : 'enabled'} for ${provider.type} provider.`; + } else if (updates.allowConnectedAccounts !== undefined) { + successMessage = `Connected accounts ${provider.allowConnectedAccounts ? 'disabled' : 'enabled'} for ${provider.type} provider.`; + } + toast({ + title: "Success", + description: successMessage, + variant: "success", + }); + } + }; + + const toggleAllowSignIn = async (provider: ServerOAuthProvider) => { + await handleProviderUpdate(provider, { allowSignIn: !provider.allowSignIn }); + }; + + const toggleAllowConnectedAccounts = async (provider: ServerOAuthProvider) => { + await handleProviderUpdate(provider, { allowConnectedAccounts: !provider.allowConnectedAccounts }); + }; + + return ( +
+
+
+

OAuth Providers

+
+ +
+ + + + {editingProvider && ( + setEditingProvider(null)} + mode="edit" + provider={editingProvider} + /> + )} + + {oauthProviders.length === 0 ? ( +
+

+ No OAuth providers connected +

+
+ ) : ( +
+ + + + Provider + Email + Account ID + Used for sign-in + Used for connected accounts + + + + + {oauthProviders.map((provider: ServerOAuthProvider) => ( + + +
+
+ {provider.type} +
+
+
+ +
+ {provider.email} +
+
+ +
+ {provider.accountId} +
+
+ + {provider.allowSignIn ? + : + + } + + + {provider.allowConnectedAccounts ? + : + + } + + + setEditingProvider(provider), + }, + { + item: provider.allowSignIn ? "Disable sign-in" : "Enable sign-in", + onClick: async () => { + await toggleAllowSignIn(provider); + }, + }, + { + item: provider.allowConnectedAccounts ? "Disable connected accounts" : "Enable connected accounts", + onClick: async () => { + await toggleAllowConnectedAccounts(provider); + }, + }, + { + item: "Delete", + danger: true, + onClick: async () => { + await provider.delete(); + }, + } + ]} + /> + +
+ ))} +
+
+
+ )} +
+ ); +} + type MetadataSectionProps = { user: ServerUser, }; @@ -873,6 +1228,8 @@ function UserPage({ user }: { user: ServerUser }) { + +
diff --git a/apps/e2e/tests/backend/endpoints/api/v1/oauth-providers.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/oauth-providers.test.ts index 1c3f2e319a..fc98d2d61e 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/oauth-providers.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/oauth-providers.test.ts @@ -46,6 +46,7 @@ it("should create an OAuth provider connection", async ({ expect }: { expect: an "allow_sign_in": true, "email": "test@example.com", "id": "", + "provider_config_id": "spotify", "type": "spotify", "user_id": "", }, @@ -89,6 +90,7 @@ it("should read an OAuth provider connection", async ({ expect }: { expect: any "allow_sign_in": true, "email": "test@example.com", "id": "", + "provider_config_id": "spotify", "type": "spotify", "user_id": "", }, @@ -136,6 +138,7 @@ it("should list all OAuth provider connections for a user", async ({ expect }: { "allow_sign_in": true, "email": "test@example.com", "id": "", + "provider_config_id": "spotify", "type": "spotify", "user_id": "", }, @@ -399,6 +402,7 @@ it("should forbid client access to other users' OAuth providers", async ({ expec "allow_sign_in": true, "email": "test2@example.com", "id": "", + "provider_config_id": "spotify", "type": "spotify", "user_id": "", }, @@ -524,6 +528,7 @@ it("should allow server access to any user's OAuth providers", async ({ expect } "allow_sign_in": true, "email": "test1@example.com", "id": "", + "provider_config_id": "spotify", "type": "spotify", "user_id": "", }, @@ -533,6 +538,7 @@ it("should allow server access to any user's OAuth providers", async ({ expect } "allow_sign_in": true, "email": "test2@example.com", "id": "", + "provider_config_id": "spotify", "type": "spotify", "user_id": "", }, @@ -560,6 +566,7 @@ it("should allow server access to any user's OAuth providers", async ({ expect } "allow_sign_in": true, "email": "test1@example.com", "id": "", + "provider_config_id": "spotify", "type": "spotify", "user_id": "", }, @@ -587,6 +594,7 @@ it("should allow server access to any user's OAuth providers", async ({ expect } "allow_sign_in": false, "email": "test1@example.com", "id": "", + "provider_config_id": "spotify", "type": "spotify", "user_id": "", }, @@ -649,6 +657,7 @@ it("should handle account_id updates correctly", async ({ expect }: { expect: an "allow_sign_in": true, "email": "test@example.com", "id": "", + "provider_config_id": "spotify", "type": "spotify", "user_id": "", }, @@ -671,6 +680,7 @@ it("should handle account_id updates correctly", async ({ expect }: { expect: an "allow_sign_in": true, "email": "test@example.com", "id": "", + "provider_config_id": "spotify", "type": "spotify", "user_id": "", }, @@ -868,6 +878,7 @@ it("should prevent multiple providers of the same type from being enabled for si "allow_sign_in": false, "email": "user456@example.com", "id": "", + "provider_config_id": "spotify", "type": "spotify", "user_id": "", }, diff --git a/packages/stack-shared/src/interface/client-interface.ts b/packages/stack-shared/src/interface/client-interface.ts index c8c93e2cdf..5e23d48376 100644 --- a/packages/stack-shared/src/interface/client-interface.ts +++ b/packages/stack-shared/src/interface/client-interface.ts @@ -17,6 +17,7 @@ import { ConnectedAccountAccessTokenCrud } from './crud/connected-accounts'; import { ContactChannelsCrud } from './crud/contact-channels'; import { CurrentUserCrud } from './crud/current-user'; import { NotificationPreferenceCrud } from './crud/notification-preferences'; +import { OAuthProviderCrud } from './crud/oauth-providers'; import { TeamApiKeysCrud, UserApiKeysCrud, teamApiKeysCreateInputSchema, teamApiKeysCreateOutputSchema, userApiKeysCreateInputSchema, userApiKeysCreateOutputSchema } from './crud/project-api-keys'; import { ProjectPermissionsCrud } from './crud/project-permissions'; import { AdminUserProjectsCrud, ClientProjectsCrud } from './crud/projects'; @@ -1673,53 +1674,51 @@ export class StackClientInterface { async getOAuthProvider( userId: string, providerId: string, - session: InternalSession | null, - requestType: "client" | "server" | "admin" = "client", - ): Promise<{ - id: string, - type: string, - user_id: string, - account_id?: string, - email: string, - allow_sign_in: boolean, - allow_connected_accounts: boolean, - }> { - const sendRequest = requestType === "client" ? this.sendClientRequest : (this as any).sendServerRequest; - const response = await sendRequest.call(this, + session: InternalSession, + ): Promise { + const response = await this.sendClientRequest( `/oauth-providers/${userId}/${providerId}`, { method: "GET", }, session, - requestType, ); - return response.json(); + return await response.json(); + } + + async updateOAuthProvider( + userId: string, + providerId: string, + data: OAuthProviderCrud['Client']['Update'], + session: InternalSession, + ): Promise { + const response = await this.sendClientRequest( + `/oauth-providers/${userId}/${providerId}`, + { + method: "PATCH", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify(data), + }, + session, + ); + return await response.json(); } async listOAuthProviders( options: { user_id?: string, } = {}, - session: InternalSession | null, - requestType: "client" | "server" | "admin" = "client", - ): Promise<{ - id: string, - type: string, - user_id: string, - account_id?: string, - email: string, - allow_sign_in: boolean, - allow_connected_accounts: boolean, - }[]> { - const sendRequest = requestType === "client" ? this.sendClientRequest : (this as any).sendServerRequest; + session: InternalSession, + ): Promise { const queryParams = new URLSearchParams(filterUndefined(options)); - const response = await sendRequest.call(this, + const response = await this.sendClientRequest( `/oauth-providers${queryParams.toString() ? `?${queryParams.toString()}` : ''}`, { method: "GET", }, session, - requestType, ); const result = await response.json(); return result.items; @@ -1728,19 +1727,16 @@ export class StackClientInterface { async deleteOAuthProvider( userId: string, providerId: string, - session: InternalSession | null, - requestType: "client" | "server" | "admin" = "client", - ): Promise<{ success: boolean }> { - const sendRequest = requestType === "client" ? this.sendClientRequest : (this as any).sendServerRequest; - const response = await sendRequest.call(this, + session: InternalSession, + ): Promise { + const response = await this.sendClientRequest( `/oauth-providers/${userId}/${providerId}`, { method: "DELETE", }, session, - requestType, ); - return response.json(); + return await response.json(); } } diff --git a/packages/stack-shared/src/interface/crud/oauth-providers.ts b/packages/stack-shared/src/interface/crud/oauth-providers.ts index f9a8976777..d1983e59f3 100644 --- a/packages/stack-shared/src/interface/crud/oauth-providers.ts +++ b/packages/stack-shared/src/interface/crud/oauth-providers.ts @@ -5,6 +5,7 @@ import { oauthProviderAllowSignInSchema, oauthProviderEmailSchema, oauthProviderIdSchema, + oauthProviderProviderConfigIdSchema, oauthProviderTypeSchema, userIdOrMeSchema, yupMixed, @@ -16,6 +17,7 @@ export const oauthProviderClientReadSchema = yupObject({ user_id: userIdOrMeSchema.defined(), id: oauthProviderIdSchema.defined(), email: oauthProviderEmailSchema.optional(), + provider_config_id: oauthProviderProviderConfigIdSchema.defined(), type: oauthProviderTypeSchema.defined(), allow_sign_in: oauthProviderAllowSignInSchema.defined(), allow_connected_accounts: oauthProviderAllowConnectedAccountsSchema.defined(), diff --git a/packages/stack-shared/src/interface/server-interface.ts b/packages/stack-shared/src/interface/server-interface.ts index 8d847b4400..abd304212c 100644 --- a/packages/stack-shared/src/interface/server-interface.ts +++ b/packages/stack-shared/src/interface/server-interface.ts @@ -12,6 +12,7 @@ import { ConnectedAccountAccessTokenCrud } from "./crud/connected-accounts"; import { ContactChannelsCrud } from "./crud/contact-channels"; import { CurrentUserCrud } from "./crud/current-user"; import { NotificationPreferenceCrud } from "./crud/notification-preferences"; +import { OAuthProviderCrud } from "./crud/oauth-providers"; import { ProjectPermissionsCrud } from "./crud/project-permissions"; import { SessionsCrud } from "./crud/sessions"; import { TeamInvitationCrud } from "./crud/team-invitation"; @@ -693,23 +694,8 @@ export class StackServerInterface extends StackClientInterface { // OAuth Providers CRUD operations async createServerOAuthProvider( - data: { - user_id: string, - provider_config_id: string, - account_id: string, - email: string, - allow_sign_in: boolean, - allow_connected_accounts: boolean, - }, - ): Promise<{ - id: string, - type: string, - user_id: string, - account_id: string, - email: string, - allow_sign_in: boolean, - allow_connected_accounts: boolean, - }> { + data: OAuthProviderCrud['Server']['Create'], + ): Promise { const response = await this.sendServerRequest( "/oauth-providers", { @@ -729,15 +715,7 @@ export class StackServerInterface extends StackClientInterface { options: { user_id?: string, } = {}, - ): Promise<{ - id: string, - type: string, - user_id: string, - account_id: string, - email: string, - allow_sign_in: boolean, - allow_connected_accounts: boolean, - }[]> { + ): Promise { const queryParams = new URLSearchParams(filterUndefined(options)); const response = await this.sendServerRequest( `/oauth-providers${queryParams.toString() ? `?${queryParams.toString()}` : ''}`, @@ -753,21 +731,8 @@ export class StackServerInterface extends StackClientInterface { async updateServerOAuthProvider( userId: string, providerId: string, - data: { - account_id?: string, - email?: string, - allow_sign_in?: boolean, - allow_connected_accounts?: boolean, - }, - ): Promise<{ - id: string, - type: string, - user_id: string, - account_id: string, - email: string, - allow_sign_in: boolean, - allow_connected_accounts: boolean, - }> { + data: OAuthProviderCrud['Server']['Update'], + ): Promise { const response = await this.sendServerRequest( urlString`/oauth-providers/${userId}/${providerId}`, { @@ -785,7 +750,7 @@ export class StackServerInterface extends StackClientInterface { async deleteServerOAuthProvider( userId: string, providerId: string, - ): Promise<{ success: boolean }> { + ): Promise { const response = await this.sendServerRequest( urlString`/oauth-providers/${userId}/${providerId}`, { diff --git a/packages/stack-shared/src/schema-fields.ts b/packages/stack-shared/src/schema-fields.ts index 559a8e36e1..ac22685b15 100644 --- a/packages/stack-shared/src/schema-fields.ts +++ b/packages/stack-shared/src/schema-fields.ts @@ -605,6 +605,7 @@ export const oauthProviderTypeSchema = yupString().oneOf(allProviders).meta({ op export const oauthProviderAllowSignInSchema = yupBoolean().meta({ openapiField: { description: 'Whether the user can use this OAuth provider to sign in. Only one OAuth provider per type can have this set to `true`.', exampleValue: true } }); export const oauthProviderAllowConnectedAccountsSchema = yupBoolean().meta({ openapiField: { description: 'Whether the user can use this OAuth provider as connected account. Multiple OAuth providers per type can have this set to `true`.', exampleValue: true } }); export const oauthProviderAccountIdSchema = yupString().meta({ openapiField: { description: 'Account ID of the OAuth provider. This uniquely identifies the account on the provider side.', exampleValue: 'google-account-id-12345' } }); +export const oauthProviderProviderConfigIdSchema = yupString().meta({ openapiField: { description: 'Provider config ID of the OAuth provider. This uniquely identifies the provider config on config.json file', exampleValue: 'google' } }); // Headers export const basicAuthorizationHeaderSchema = yupString().test('is-basic-authorization-header', 'Authorization header must be in the format "Basic "', (value) => { diff --git a/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts b/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts index b6f83e32a0..04d0971e41 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts @@ -2,6 +2,8 @@ import { WebAuthnError, startAuthentication, startRegistration } from "@simplewe import { KnownErrors, StackClientInterface } from "@stackframe/stack-shared"; import { ContactChannelsCrud } from "@stackframe/stack-shared/dist/interface/crud/contact-channels"; import { CurrentUserCrud } from "@stackframe/stack-shared/dist/interface/crud/current-user"; +import { NotificationPreferenceCrud } from "@stackframe/stack-shared/dist/interface/crud/notification-preferences"; +import { OAuthProviderCrud } from "@stackframe/stack-shared/dist/interface/crud/oauth-providers"; import { TeamApiKeysCrud, UserApiKeysCrud, teamApiKeysCreateOutputSchema, userApiKeysCreateOutputSchema } from "@stackframe/stack-shared/dist/interface/crud/project-api-keys"; import { ProjectPermissionsCrud } from "@stackframe/stack-shared/dist/interface/crud/project-permissions"; import { ClientProjectsCrud } from "@stackframe/stack-shared/dist/interface/crud/projects"; @@ -11,7 +13,6 @@ import { TeamMemberProfilesCrud } from "@stackframe/stack-shared/dist/interface/ import { TeamPermissionsCrud } from "@stackframe/stack-shared/dist/interface/crud/team-permissions"; import { TeamsCrud } from "@stackframe/stack-shared/dist/interface/crud/teams"; import { UsersCrud } from "@stackframe/stack-shared/dist/interface/crud/users"; -import { NotificationPreferenceCrud } from "@stackframe/stack-shared/dist/interface/crud/notification-preferences"; import { InternalSession } from "@stackframe/stack-shared/dist/sessions"; import { scrambleDuringCompileTime } from "@stackframe/stack-shared/dist/utils/compile-time"; import { isBrowserLike } from "@stackframe/stack-shared/dist/utils/env"; @@ -39,7 +40,7 @@ import { NotificationCategory } from "../../notification-categories"; import { TeamPermission } from "../../permissions"; import { AdminOwnedProject, AdminProjectUpdateOptions, Project, adminProjectCreateOptionsToCrud } from "../../projects"; import { EditableTeamMemberProfile, Team, TeamCreateOptions, TeamInvitation, TeamUpdateOptions, TeamUser, teamCreateOptionsToCrud, teamUpdateOptionsToCrud } from "../../teams"; -import { ActiveSession, Auth, BaseUser, CurrentUser, InternalUserExtra, ProjectCurrentUser, UserExtra, UserUpdateOptions, userUpdateOptionsToCrud } from "../../users"; +import { ActiveSession, Auth, BaseUser, CurrentUser, InternalUserExtra, OAuthProvider, ProjectCurrentUser, UserExtra, UserUpdateOptions, userUpdateOptionsToCrud } from "../../users"; import { StackClientApp, StackClientAppConstructorOptions, StackClientAppJson } from "../interfaces/client-app"; import { _StackAdminAppImplIncomplete } from "./admin-app-impl"; import { TokenObject, clientVersion, createCache, createCacheBySession, createEmptyTokenStore, getBaseUrl, getDefaultExtraRequestHeaders, getDefaultProjectId, getDefaultPublishableClientKey, getUrls, } from "./common"; @@ -200,6 +201,12 @@ export class _StackClientAppImplIncomplete( + async (session) => { + return await this._interface.listOAuthProviders({ user_id: 'me' }, session); + } + ); + private _anonymousSignUpInProgress: Promise<{ accessToken: string, refreshToken: string }> | null = null; protected async _createCookieHelper(): Promise { @@ -808,6 +815,43 @@ export class _StackClientAppImplIncomplete + >> { + try { + await app._interface.updateOAuthProvider( + crud.id, + crud.provider_config_id, + { + allow_sign_in: data.allowSignIn, + allow_connected_accounts: data.allowConnectedAccounts, + }, session); + await app._currentUserOAuthProvidersCache.refresh([session]); + return Result.ok(undefined); + } catch (error) { + if (KnownErrors.OAuthProviderAccountIdAlreadyUsedForSignIn.isInstance(error)) { + return Result.error(error); + } + throw error; + } + }, + + async delete() { + await app._interface.deleteOAuthProvider(crud.user_id, crud.id, session); + await app._currentUserOAuthProvidersCache.refresh([session]); + }, + }; + } protected _createAuth(session: InternalSession): Auth { const app = this; return { @@ -1132,7 +1176,29 @@ export class _StackClientAppImplIncomplete app._clientOAuthProviderFromCrud(crud, session)); + }, + // END_PLATFORM + async listOAuthProviders() { + const results = Result.orThrow(await app._currentUserOAuthProvidersCache.getOrWait([session], "write-only")); + return results.map((crud) => app._clientOAuthProviderFromCrud(crud, session)); + }, + + // IF_PLATFORM react-like + useOAuthProvider(id: string) { + const providers = this.useOAuthProviders(); + return useMemo(() => providers.find((p) => p.id === id) ?? null, [providers, id]); + }, + // END_PLATFORM + + async getOAuthProvider(id: string) { + const providers = await this.listOAuthProviders(); + return providers.find((p) => p.id === id) ?? null; + }, }; } diff --git a/packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts b/packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts index 6d495aa232..75600a8924 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts @@ -1,6 +1,7 @@ import { KnownErrors, StackServerInterface } from "@stackframe/stack-shared"; import { ContactChannelsCrud } from "@stackframe/stack-shared/dist/interface/crud/contact-channels"; import { NotificationPreferenceCrud } from "@stackframe/stack-shared/dist/interface/crud/notification-preferences"; +import { OAuthProviderCrud } from "@stackframe/stack-shared/dist/interface/crud/oauth-providers"; import { TeamApiKeysCrud, UserApiKeysCrud, teamApiKeysCreateOutputSchema, userApiKeysCreateOutputSchema } from "@stackframe/stack-shared/dist/interface/crud/project-api-keys"; import { ProjectPermissionDefinitionsCrud, ProjectPermissionsCrud } from "@stackframe/stack-shared/dist/interface/crud/project-permissions"; import { TeamInvitationCrud } from "@stackframe/stack-shared/dist/interface/crud/team-invitation"; @@ -24,7 +25,7 @@ import { ServerContactChannel, ServerContactChannelCreateOptions, ServerContactC import { NotificationCategory } from "../../notification-categories"; import { AdminProjectPermissionDefinition, AdminTeamPermission, AdminTeamPermissionDefinition } from "../../permissions"; import { EditableTeamMemberProfile, ServerListUsersOptions, ServerTeam, ServerTeamCreateOptions, ServerTeamUpdateOptions, ServerTeamUser, Team, TeamInvitation, serverTeamCreateOptionsToCrud, serverTeamUpdateOptionsToCrud } from "../../teams"; -import { ProjectCurrentServerUser, ServerUser, ServerUserCreateOptions, ServerUserUpdateOptions, serverUserCreateOptionsToCrud, serverUserUpdateOptionsToCrud } from "../../users"; +import { ProjectCurrentServerUser, ServerOAuthProvider, ServerUser, ServerUserCreateOptions, ServerUserUpdateOptions, serverUserCreateOptionsToCrud, serverUserUpdateOptionsToCrud } from "../../users"; import { StackServerAppConstructorOptions } from "../interfaces/server-app"; import { _StackClientAppImplIncomplete } from "./client-app-impl"; import { clientVersion, createCache, createCacheBySession, getBaseUrl, getDefaultProjectId, getDefaultPublishableClientKey, getDefaultSecretServerKey } from "./common"; @@ -154,6 +155,12 @@ export class _StackServerAppImplIncomplete( + async ([userId]) => { + return await this._interface.listServerOAuthProviders({ user_id: userId }); + } + ); + private async _updateServerUser(userId: string, update: ServerUserUpdateOptions): Promise { const result = await this._interface.updateServerUser(userId, serverUserUpdateOptionsToCrud(update)); await this._refreshUsers(); @@ -223,6 +230,44 @@ export class _StackServerAppImplIncomplete + >> { + try { + await app._interface.updateServerOAuthProvider(crud.user_id, crud.id, { + account_id: data.accountId, + email: data.email, + allow_sign_in: data.allowSignIn, + allow_connected_accounts: data.allowConnectedAccounts, + }); + await app._serverOAuthProvidersCache.refresh([crud.user_id]); + return Result.ok(undefined); + } catch (error) { + if (KnownErrors.OAuthProviderAccountIdAlreadyUsedForSignIn.isInstance(error)) { + return Result.error(error); + } + throw error; + } + }, + + async delete() { + await app._interface.deleteServerOAuthProvider(crud.user_id, crud.id); + await app._serverOAuthProvidersCache.refresh([crud.user_id]); + }, + }; + } + constructor(options: | StackServerAppConstructorOptions | { @@ -554,6 +599,29 @@ export class _StackServerAppImplIncomplete results.map((oauthCrud) => app._serverOAuthProviderFromCrud(oauthCrud)), [results]); + }, + // END_PLATFORM + + async listOAuthProviders() { + const results = Result.orThrow(await app._serverOAuthProvidersCache.getOrWait([crud.id], "write-only")); + return results.map((oauthCrud) => app._serverOAuthProviderFromCrud(oauthCrud)); + }, + + // IF_PLATFORM react-like + useOAuthProvider(id: string) { + const providers = this.useOAuthProviders(); + return useMemo(() => providers.find((p) => p.id === id) ?? null, [providers, id]); + }, + // END_PLATFORM + + async getOAuthProvider(id: string) { + const providers = await this.listOAuthProviders(); + return providers.find((p) => p.id === id) ?? null; + }, }; } @@ -954,6 +1022,35 @@ export class _StackServerAppImplIncomplete true), this._serverUsersCache.refreshWhere(() => true), this._serverContactChannelsCache.refreshWhere(() => true), + this._serverOAuthProvidersCache.refreshWhere(() => true), ]); } + + async createOAuthProvider(options: { + userId: string, + providerConfigId: string, + accountId: string, + email: string, + allowSignIn: boolean, + allowConnectedAccounts: boolean, + }): Promise>> { + try { + const crud = await this._interface.createServerOAuthProvider({ + user_id: options.userId, + provider_config_id: options.providerConfigId, + account_id: options.accountId, + email: options.email, + allow_sign_in: options.allowSignIn, + allow_connected_accounts: options.allowConnectedAccounts, + }); + + await this._serverOAuthProvidersCache.refresh([options.userId]); + return Result.ok(this._serverOAuthProviderFromCrud(crud)); + } catch (error) { + if (KnownErrors.OAuthProviderAccountIdAlreadyUsedForSignIn.isInstance(error)) { + return Result.error(error); + } + throw error; + } + } } diff --git a/packages/template/src/lib/stack-app/apps/interfaces/server-app.ts b/packages/template/src/lib/stack-app/apps/interfaces/server-app.ts index 06a16a3360..63b67b34d4 100644 --- a/packages/template/src/lib/stack-app/apps/interfaces/server-app.ts +++ b/packages/template/src/lib/stack-app/apps/interfaces/server-app.ts @@ -1,6 +1,8 @@ +import { KnownErrors } from "@stackframe/stack-shared"; +import { Result } from "@stackframe/stack-shared/dist/utils/results"; import { AsyncStoreProperty, GetUserOptions } from "../../common"; import { ServerListUsersOptions, ServerTeam, ServerTeamCreateOptions } from "../../teams"; -import { ProjectCurrentServerUser, ServerUser, ServerUserCreateOptions } from "../../users"; +import { ProjectCurrentServerUser, ServerOAuthProvider, ServerUser, ServerUserCreateOptions } from "../../users"; import { _StackServerAppImpl } from "../implementations"; import { StackClientApp, StackClientAppConstructorOptions } from "./client-app"; @@ -46,6 +48,15 @@ export type StackServerApp, + + createOAuthProvider(options: { + userId: string, + accountId: string, + providerConfigId: string, + email: string, + allowSignIn: boolean, + allowConnectedAccounts: boolean, + }): Promise>>, } & AsyncStoreProperty<"user", [id: string], ServerUser | null, false> & Omit, "listUsers" | "useUsers"> diff --git a/packages/template/src/lib/stack-app/index.ts b/packages/template/src/lib/stack-app/index.ts index 365f747879..39056259c4 100644 --- a/packages/template/src/lib/stack-app/index.ts +++ b/packages/template/src/lib/stack-app/index.ts @@ -48,14 +48,13 @@ export type { } from "./email"; export type { - AdminTeamPermission, - AdminTeamPermissionDefinition, - AdminTeamPermissionDefinitionCreateOptions, - AdminTeamPermissionDefinitionUpdateOptions, AdminProjectPermission, AdminProjectPermissionDefinition, AdminProjectPermissionDefinitionCreateOptions, - AdminProjectPermissionDefinitionUpdateOptions, + AdminProjectPermissionDefinitionUpdateOptions, AdminTeamPermission, + AdminTeamPermissionDefinition, + AdminTeamPermissionDefinitionCreateOptions, + AdminTeamPermissionDefinitionUpdateOptions } from "./permissions"; export type { @@ -97,6 +96,8 @@ export type { CurrentInternalUser, CurrentServerUser, CurrentUser, + OAuthProvider, + ServerOAuthProvider, ServerUser, Session, User diff --git a/packages/template/src/lib/stack-app/users/index.ts b/packages/template/src/lib/stack-app/users/index.ts index 84b86b63d0..dece758a91 100644 --- a/packages/template/src/lib/stack-app/users/index.ts +++ b/packages/template/src/lib/stack-app/users/index.ts @@ -11,10 +11,38 @@ import { ApiKeyCreationOptions, UserApiKey, UserApiKeyFirstView } from "../api-k import { AsyncStoreProperty } from "../common"; import { OAuthConnection } from "../connected-accounts"; import { ContactChannel, ContactChannelCreateOptions, ServerContactChannel, ServerContactChannelCreateOptions } from "../contact-channels"; +import { NotificationCategory } from "../notification-categories"; import { AdminTeamPermission, TeamPermission } from "../permissions"; import { AdminOwnedProject, AdminProjectUpdateOptions } from "../projects"; import { EditableTeamMemberProfile, ServerTeam, ServerTeamCreateOptions, Team, TeamCreateOptions } from "../teams"; -import { NotificationCategory } from "../notification-categories"; + +export type OAuthProvider = { + readonly id: string, + readonly type: string, + readonly userId: string, + readonly accountId?: string, + readonly email?: string, + readonly allowSignIn: boolean, + readonly allowConnectedAccounts: boolean, + update(data: { allowSignIn?: boolean, allowConnectedAccounts?: boolean }): Promise + >>, + delete(): Promise, +}; + +export type ServerOAuthProvider = { + readonly id: string, + readonly type: string, + readonly userId: string, + readonly accountId: string, + readonly email?: string, + readonly allowSignIn: boolean, + readonly allowConnectedAccounts: boolean, + update(data: { accountId?: string, email?: string, allowSignIn?: boolean, allowConnectedAccounts?: boolean }): Promise + >>, + delete(): Promise, +}; export type Session = { @@ -220,6 +248,12 @@ export type UserExtra = { useTeamProfile(team: Team): EditableTeamMemberProfile, // THIS_LINE_PLATFORM react-like createApiKey(options: ApiKeyCreationOptions<"user">): Promise, + + useOAuthProviders(): OAuthProvider[], // THIS_LINE_PLATFORM react-like + listOAuthProviders(): Promise, + + useOAuthProvider(id: string): OAuthProvider | null, // THIS_LINE_PLATFORM react-like + getOAuthProvider(id: string): Promise, } & AsyncStoreProperty<"apiKeys", [], UserApiKey[], true> & AsyncStoreProperty<"team", [id: string], Team | null, false> @@ -314,6 +348,12 @@ export type ServerBaseUser = { usePermission(permissionId: string): TeamPermission | null, // END_PLATFORM + useOAuthProviders(): ServerOAuthProvider[], // THIS_LINE_PLATFORM react-like + listOAuthProviders(): Promise, + + useOAuthProvider(id: string): ServerOAuthProvider | null, // THIS_LINE_PLATFORM react-like + getOAuthProvider(id: string): Promise, + /** * Creates a new session object with a refresh token for this user. Can be used to impersonate them. */