diff --git a/e2e/src/specs/server/api/oauth.e2e-spec.ts b/e2e/src/specs/server/api/oauth.e2e-spec.ts index 9dcb431a4b..8e095c7ee3 100644 --- a/e2e/src/specs/server/api/oauth.e2e-spec.ts +++ b/e2e/src/specs/server/api/oauth.e2e-spec.ts @@ -356,19 +356,17 @@ describe(`/oauth`, () => { expect(body).toEqual(errorDto.badRequest('User does not exist and auto registering is disabled.')); }); - it('should link to an existing user by email', async () => { - const { userId } = await utils.userSetup(admin.accessToken, { + it('should not auto-link to an existing user by email', async () => { + await utils.userSetup(admin.accessToken, { name: 'OAuth User 3', email: 'oauth-user3@immich.app', password: 'password', }); const callbackParams = await loginWithOAuth('oauth-user3'); const { status, body } = await request(app).post('/oauth/callback').send(callbackParams); - expect(status).toBe(201); - expect(body).toMatchObject({ - userId, - userEmail: 'oauth-user3@immich.app', - }); + expect(status).toBe(400); + expect(body.message).toBe('oauth_account_link_required'); + expect(body.userEmail).toBe('oauth-user3@immich.app'); }); }); }); diff --git a/i18n/en.json b/i18n/en.json index add755c05d..8e02d4ee41 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1642,6 +1642,8 @@ "notifications": "Notifications", "notifications_setting_description": "Manage notifications", "oauth": "OAuth", + "oauth_link_existing_account": "Log in with your Immich password to link your OAuth account.", + "oauth_link_password_login_required": "An account with this email already exists. Password login is required to link your OAuth account. Please contact your administrator.", "obtainium_configurator": "Obtainium Configurator", "obtainium_configurator_instructions": "Use Obtainium to install and update the Android app directly from Immich GitHub's release. Create an API key and select a variant to create your Obtainium configuration link", "ocr": "OCR", diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 852fe907e0..720c67fe98 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -7448,7 +7448,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/OAuthCallbackDto" + "$ref": "#/components/schemas/OAuthLinkDto" } } }, @@ -19116,6 +19116,27 @@ ], "type": "object" }, + "OAuthLinkDto": { + "properties": { + "codeVerifier": { + "description": "OAuth code verifier (PKCE)", + "type": "string" + }, + "linkToken": { + "description": "OAuth link token from prior callback", + "type": "string" + }, + "state": { + "description": "OAuth state parameter", + "type": "string" + }, + "url": { + "description": "OAuth callback URL", + "type": "string" + } + }, + "type": "object" + }, "OAuthTokenEndpointAuthMethod": { "description": "OAuth token endpoint auth method", "enum": [ diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index da24059a2a..78eb900512 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -1421,6 +1421,16 @@ export type OAuthCallbackDto = { /** OAuth callback URL */ url: string; }; +export type OAuthLinkDto = { + /** OAuth code verifier (PKCE) */ + codeVerifier?: string; + /** OAuth link token from prior callback */ + linkToken?: string; + /** OAuth state parameter */ + state?: string; + /** OAuth callback URL */ + url?: string; +}; export type PartnerResponseDto = { avatarColor: UserAvatarColor; /** User email */ @@ -4947,8 +4957,8 @@ export function finishOAuth({ oAuthCallbackDto }: { /** * Link OAuth account */ -export function linkOAuthAccount({ oAuthCallbackDto }: { - oAuthCallbackDto: OAuthCallbackDto; +export function linkOAuthAccount({ oAuthLinkDto }: { + oAuthLinkDto: OAuthLinkDto; }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; @@ -4956,7 +4966,7 @@ export function linkOAuthAccount({ oAuthCallbackDto }: { }>("/oauth/link", oazapfts.json({ ...opts, method: "POST", - body: oAuthCallbackDto + body: oAuthLinkDto }))); } /** diff --git a/server/src/controllers/oauth.controller.ts b/server/src/controllers/oauth.controller.ts index 7f2313a058..4cbb636e2a 100644 --- a/server/src/controllers/oauth.controller.ts +++ b/server/src/controllers/oauth.controller.ts @@ -9,6 +9,7 @@ import { OAuthBackchannelLogoutDto, OAuthCallbackDto, OAuthConfigDto, + OAuthLinkDto, } from 'src/dtos/auth.dto'; import { UserAdminResponseDto } from 'src/dtos/user.dto'; import { ApiTag, AuthType, ImmichCookie } from 'src/enum'; @@ -97,7 +98,7 @@ export class OAuthController { linkOAuthAccount( @Req() request: Request, @Auth() auth: AuthDto, - @Body() dto: OAuthCallbackDto, + @Body() dto: OAuthLinkDto, ): Promise { return this.service.link(auth, dto, request.headers); } diff --git a/server/src/dtos/auth.dto.ts b/server/src/dtos/auth.dto.ts index 1f75401e33..297ae80f7c 100644 --- a/server/src/dtos/auth.dto.ts +++ b/server/src/dtos/auth.dto.ts @@ -110,6 +110,18 @@ const OAuthCallbackSchema = z }) .meta({ id: 'OAuthCallbackDto' }); +const OAuthLinkSchema = z + .object({ + url: z.string().optional().describe('OAuth callback URL'), + state: z.string().optional().describe('OAuth state parameter'), + codeVerifier: z.string().optional().describe('OAuth code verifier (PKCE)'), + linkToken: z.string().optional().describe('OAuth link token from prior callback'), + }) + .refine((data) => data.url || data.linkToken, { + message: 'Either url or linkToken is required', + }) + .meta({ id: 'OAuthLinkDto' }); + const OAuthConfigSchema = z .object({ redirectUri: z.string().describe('OAuth redirect URI'), @@ -149,6 +161,7 @@ export class SessionUnlockDto extends createZodDto(SessionUnlockSchema) {} export class PinCodeChangeDto extends createZodDto(PinCodeChangeSchema) {} export class ValidateAccessTokenResponseDto extends createZodDto(ValidateAccessTokenResponseSchema) {} export class OAuthCallbackDto extends createZodDto(OAuthCallbackSchema) {} +export class OAuthLinkDto extends createZodDto(OAuthLinkSchema) {} export class OAuthConfigDto extends createZodDto(OAuthConfigSchema) {} export class OAuthAuthorizeResponseDto extends createZodDto(OAuthAuthorizeResponseSchema) {} export class OAuthBackchannelLogoutDto extends createZodDto(OAuthBackchannelLogoutSchema) {} diff --git a/server/src/repositories/index.ts b/server/src/repositories/index.ts index fcff171a5e..28ceca0bb0 100644 --- a/server/src/repositories/index.ts +++ b/server/src/repositories/index.ts @@ -25,6 +25,7 @@ import { MemoryRepository } from 'src/repositories/memory.repository'; import { MetadataRepository } from 'src/repositories/metadata.repository'; import { MoveRepository } from 'src/repositories/move.repository'; import { NotificationRepository } from 'src/repositories/notification.repository'; +import { OAuthLinkTokenRepository } from 'src/repositories/oauth-link-token.repository'; import { OAuthRepository } from 'src/repositories/oauth.repository'; import { OcrRepository } from 'src/repositories/ocr.repository'; import { PartnerRepository } from 'src/repositories/partner.repository'; @@ -78,6 +79,7 @@ export const repositories = [ MetadataRepository, MoveRepository, NotificationRepository, + OAuthLinkTokenRepository, OAuthRepository, OcrRepository, PartnerRepository, diff --git a/server/src/repositories/oauth-link-token.repository.ts b/server/src/repositories/oauth-link-token.repository.ts new file mode 100644 index 0000000000..4e03d52fd5 --- /dev/null +++ b/server/src/repositories/oauth-link-token.repository.ts @@ -0,0 +1,37 @@ +import { Injectable } from '@nestjs/common'; +import { Insertable, Kysely } from 'kysely'; +import { DateTime } from 'luxon'; +import { InjectKysely } from 'nestjs-kysely'; +import { DB } from 'src/schema'; +import { OAuthLinkTokenTable } from 'src/schema/tables/oauth-link-token.table'; + +@Injectable() +export class OAuthLinkTokenRepository { + constructor(@InjectKysely() private db: Kysely) {} + + create(dto: Insertable) { + return this.db.insertInto('oauth_link_token').values(dto).returningAll().executeTakeFirstOrThrow(); + } + + // Atomic consume: delete and return in one query (single-use guarantee) + consumeToken(token: Buffer) { + return this.db + .deleteFrom('oauth_link_token') + .where('token', '=', token) + .where('expiresAt', '>', DateTime.now().toJSDate()) + .returningAll() + .executeTakeFirst(); + } + + async deleteByEmail(userEmail: string) { + await this.db.deleteFrom('oauth_link_token').where('userEmail', '=', userEmail).execute(); + } + + async cleanup() { + const result = await this.db + .deleteFrom('oauth_link_token') + .where('expiresAt', '<=', DateTime.now().toJSDate()) + .execute(); + return Number(result[0]?.numDeletedRows ?? 0); + } +} diff --git a/server/src/schema/index.ts b/server/src/schema/index.ts index e3db3d01c7..ebcc0901a6 100644 --- a/server/src/schema/index.ts +++ b/server/src/schema/index.ts @@ -50,6 +50,7 @@ import { MemoryTable } from 'src/schema/tables/memory.table'; import { MoveTable } from 'src/schema/tables/move.table'; import { NaturalEarthCountriesTable } from 'src/schema/tables/natural-earth-countries.table'; import { NotificationTable } from 'src/schema/tables/notification.table'; +import { OAuthLinkTokenTable } from 'src/schema/tables/oauth-link-token.table'; import { OcrSearchTable } from 'src/schema/tables/ocr-search.table'; import { PartnerAuditTable } from 'src/schema/tables/partner-audit.table'; import { PartnerTable } from 'src/schema/tables/partner.table'; @@ -108,6 +109,7 @@ export class ImmichDatabase { MoveTable, NaturalEarthCountriesTable, NotificationTable, + OAuthLinkTokenTable, OcrSearchTable, PartnerAuditTable, PartnerTable, @@ -210,6 +212,8 @@ export interface DB { notification: NotificationTable; + oauth_link_token: OAuthLinkTokenTable; + move_history: MoveTable; naturalearth_countries: NaturalEarthCountriesTable; diff --git a/server/src/schema/migrations/1776362646907-CreateOAuthLinkTokenTable.ts b/server/src/schema/migrations/1776362646907-CreateOAuthLinkTokenTable.ts new file mode 100644 index 0000000000..b9a0cdbc5b --- /dev/null +++ b/server/src/schema/migrations/1776362646907-CreateOAuthLinkTokenTable.ts @@ -0,0 +1,15 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`CREATE TABLE "oauth_link_token" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "token" bytea NOT NULL, "oauthSub" character varying NOT NULL, "userEmail" character varying NOT NULL, "expiresAt" timestamp with time zone NOT NULL, "createdAt" timestamp with time zone NOT NULL DEFAULT now());`.execute( + db, + ); + await sql`ALTER TABLE "oauth_link_token" ADD CONSTRAINT "PK_oauth_link_token_id" PRIMARY KEY ("id");`.execute(db); + await sql`CREATE INDEX "IDX_oauth_link_token_token" ON "oauth_link_token" ("token")`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`DROP INDEX "IDX_oauth_link_token_token";`.execute(db); + await sql`ALTER TABLE "oauth_link_token" DROP CONSTRAINT "PK_oauth_link_token_id";`.execute(db); + await sql`DROP TABLE "oauth_link_token";`.execute(db); +} diff --git a/server/src/schema/tables/oauth-link-token.table.ts b/server/src/schema/tables/oauth-link-token.table.ts new file mode 100644 index 0000000000..beadc1bc9d --- /dev/null +++ b/server/src/schema/tables/oauth-link-token.table.ts @@ -0,0 +1,22 @@ +import { Column, CreateDateColumn, Generated, PrimaryGeneratedColumn, Table, Timestamp } from '@immich/sql-tools'; + +@Table({ name: 'oauth_link_token' }) +export class OAuthLinkTokenTable { + @PrimaryGeneratedColumn() + id!: Generated; + + @Column({ type: 'bytea', index: true }) + token!: Buffer; + + @Column() + oauthSub!: string; + + @Column() + userEmail!: string; + + @Column({ type: 'timestamp with time zone' }) + expiresAt!: Timestamp; + + @CreateDateColumn() + createdAt!: Generated; +} diff --git a/server/src/services/auth.service.spec.ts b/server/src/services/auth.service.spec.ts index a824b68814..3046592838 100644 --- a/server/src/services/auth.service.spec.ts +++ b/server/src/services/auth.service.spec.ts @@ -702,24 +702,28 @@ describe(AuthService.name, () => { expect(mocks.user.getByEmail).toHaveBeenCalledTimes(1); }); - it('should link an existing user', async () => { + it('should reject when existing user found by email and create a link token', async () => { const user = UserFactory.create(); const profile = OAuthProfileFactory.create(); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled); mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile }); mocks.user.getByEmail.mockResolvedValue(user); - mocks.user.update.mockResolvedValue(user); - mocks.session.create.mockResolvedValue(SessionFactory.create()); + mocks.oauthLinkToken.deleteByEmail.mockResolvedValue(); + mocks.oauthLinkToken.create.mockResolvedValue({} as any); - await sut.callback( - { url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foobar' }, - {}, - loginDetails, - ); + await expect( + sut.callback( + { url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foobar' }, + {}, + loginDetails, + ), + ).rejects.toThrow('oauth_account_link_required'); expect(mocks.user.getByEmail).toHaveBeenCalledTimes(1); - expect(mocks.user.update).toHaveBeenCalledWith(user.id, { oauthId: profile.sub }); + expect(mocks.user.update).not.toHaveBeenCalled(); + expect(mocks.oauthLinkToken.deleteByEmail).toHaveBeenCalledTimes(1); + expect(mocks.oauthLinkToken.create).toHaveBeenCalledTimes(1); }); it('should normalize the email from the OAuth profile before linking', async () => { @@ -749,6 +753,8 @@ describe(AuthService.name, () => { mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile: OAuthProfileFactory.create() }); mocks.user.getByEmail.mockResolvedValueOnce(user); mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true })); + mocks.oauthLinkToken.deleteByEmail.mockResolvedValue(); + mocks.oauthLinkToken.create.mockResolvedValue({} as any); await expect( sut.callback( @@ -756,7 +762,7 @@ describe(AuthService.name, () => { {}, loginDetails, ), - ).rejects.toThrow(BadRequestException); + ).rejects.toThrow('oauth_account_link_required'); expect(mocks.user.update).not.toHaveBeenCalled(); expect(mocks.user.create).not.toHaveBeenCalled(); diff --git a/server/src/services/auth.service.ts b/server/src/services/auth.service.ts index 628e863712..db58c925e7 100644 --- a/server/src/services/auth.service.ts +++ b/server/src/services/auth.service.ts @@ -320,14 +320,23 @@ export class AuthService extends BaseService { this.logger.debug(`Logging in with OAuth: ${JSON.stringify(profile)}`); let user: UserAdmin | undefined = await this.userRepository.getByOAuthId(profile.sub); - // link by email if (!user && normalizedEmail) { const emailUser = await this.userRepository.getByEmail(normalizedEmail); if (emailUser) { - if (emailUser.oauthId) { - throw new BadRequestException('User already exists, but is linked to another account.'); - } - user = await this.userRepository.update(emailUser.id, { oauthId: profile.sub }); + await this.oauthLinkTokenRepository.deleteByEmail(emailUser.email); + const plainToken = this.cryptoRepository.randomBytesAsText(32); + const hashedToken = this.cryptoRepository.hashSha256(plainToken); + await this.oauthLinkTokenRepository.create({ + token: hashedToken, + oauthSub: profile.sub, + userEmail: emailUser.email, + expiresAt: new Date(Date.now() + 10 * 60 * 1000), + }); + throw new BadRequestException({ + message: 'oauth_account_link_required', + userEmail: emailUser.email, + linkToken: plainToken, + }); } } diff --git a/server/src/services/base.service.ts b/server/src/services/base.service.ts index 4b02d6e944..c751687bcc 100644 --- a/server/src/services/base.service.ts +++ b/server/src/services/base.service.ts @@ -32,6 +32,7 @@ import { MemoryRepository } from 'src/repositories/memory.repository'; import { MetadataRepository } from 'src/repositories/metadata.repository'; import { MoveRepository } from 'src/repositories/move.repository'; import { NotificationRepository } from 'src/repositories/notification.repository'; +import { OAuthLinkTokenRepository } from 'src/repositories/oauth-link-token.repository'; import { OAuthRepository } from 'src/repositories/oauth.repository'; import { OcrRepository } from 'src/repositories/ocr.repository'; import { PartnerRepository } from 'src/repositories/partner.repository'; @@ -88,6 +89,7 @@ export const BASE_SERVICE_DEPENDENCIES = [ MetadataRepository, MoveRepository, NotificationRepository, + OAuthLinkTokenRepository, OAuthRepository, OcrRepository, PartnerRepository, @@ -146,6 +148,7 @@ export class BaseService { protected metadataRepository: MetadataRepository, protected moveRepository: MoveRepository, protected notificationRepository: NotificationRepository, + protected oauthLinkTokenRepository: OAuthLinkTokenRepository, protected oauthRepository: OAuthRepository, protected ocrRepository: OcrRepository, protected partnerRepository: PartnerRepository, diff --git a/server/src/services/session.service.ts b/server/src/services/session.service.ts index 735a8c2453..bd258977d3 100644 --- a/server/src/services/session.service.ts +++ b/server/src/services/session.service.ts @@ -24,6 +24,11 @@ export class SessionService extends BaseService { this.logger.log(`Deleted ${sessions.length} expired session tokens`); + const expiredLinkTokens = await this.oauthLinkTokenRepository.cleanup(); + if (expiredLinkTokens > 0) { + this.logger.log(`Deleted ${expiredLinkTokens} expired OAuth link tokens`); + } + return JobStatus.Success; } diff --git a/server/test/utils.ts b/server/test/utils.ts index 7e47afbb01..f36ee3c01b 100644 --- a/server/test/utils.ts +++ b/server/test/utils.ts @@ -43,6 +43,7 @@ import { MemoryRepository } from 'src/repositories/memory.repository'; import { MetadataRepository } from 'src/repositories/metadata.repository'; import { MoveRepository } from 'src/repositories/move.repository'; import { NotificationRepository } from 'src/repositories/notification.repository'; +import { OAuthLinkTokenRepository } from 'src/repositories/oauth-link-token.repository'; import { OAuthRepository } from 'src/repositories/oauth.repository'; import { OcrRepository } from 'src/repositories/ocr.repository'; import { PartnerRepository } from 'src/repositories/partner.repository'; @@ -239,6 +240,7 @@ export type ServiceOverrides = { metadata: MetadataRepository; move: MoveRepository; notification: NotificationRepository; + oauthLinkToken: OAuthLinkTokenRepository; ocr: OcrRepository; oauth: OAuthRepository; partner: PartnerRepository; @@ -321,6 +323,7 @@ export const getMocks = () => { move: automock(MoveRepository, { strict: false }), notification: automock(NotificationRepository), ocr: automock(OcrRepository, { strict: false }), + oauthLinkToken: automock(OAuthLinkTokenRepository), oauth: automock(OAuthRepository, { args: [loggerMock] }), partner: automock(PartnerRepository, { strict: false }), person: automock(PersonRepository, { strict: false }), @@ -387,6 +390,7 @@ export const newTestService = ( overrides.metadata || (mocks.metadata as As), overrides.move || (mocks.move as As), overrides.notification || (mocks.notification as As), + overrides.oauthLinkToken || (mocks.oauthLinkToken as As), overrides.oauth || (mocks.oauth as As), overrides.ocr || (mocks.ocr as As), overrides.partner || (mocks.partner as As), diff --git a/web/src/lib/route.ts b/web/src/lib/route.ts index 2fdfb4a052..4afa75e188 100644 --- a/web/src/lib/route.ts +++ b/web/src/lib/route.ts @@ -51,6 +51,7 @@ export const Docs = { export const Route = { // auth login: (params?: { continue?: string; autoLaunch?: 0 | 1 }) => '/auth/login' + asQueryString(params), + authLink: (params?: { linkToken?: string; email?: string }) => '/auth/link' + asQueryString(params), logout: (params?: { continue?: string }) => '/auth/logout' + asQueryString(params), register: () => '/auth/register', changePassword: () => '/auth/change-password', diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts index dc53e4f513..8012f9cb1e 100644 --- a/web/src/lib/utils.ts +++ b/web/src/lib/utils.ts @@ -294,7 +294,7 @@ export const oauth = { return finishOAuth({ oAuthCallbackDto: { url: location.href } }); }, link: (location: Location) => { - return linkOAuthAccount({ oAuthCallbackDto: { url: location.href } }); + return linkOAuthAccount({ oAuthLinkDto: { url: location.href } }); }, unlink: () => { return unlinkOAuthAccount(); diff --git a/web/src/routes/(user)/user-settings/oauth-settings.svelte b/web/src/routes/(user)/user-settings/oauth-settings.svelte index 2153ec287e..0e02fea638 100644 --- a/web/src/routes/(user)/user-settings/oauth-settings.svelte +++ b/web/src/routes/(user)/user-settings/oauth-settings.svelte @@ -2,32 +2,13 @@ import { goto } from '$app/navigation'; import { authManager } from '$lib/managers/auth-manager.svelte'; import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; + import { Route } from '$lib/route'; import { oauth } from '$lib/utils'; import { handleError } from '$lib/utils/handle-error'; - import { Button, LoadingSpinner, toastManager } from '@immich/ui'; - import { onMount } from 'svelte'; + import { Button, toastManager } from '@immich/ui'; import { t } from 'svelte-i18n'; import { fade } from 'svelte/transition'; - let loading = $state(true); - - onMount(async () => { - if (oauth.isCallback(globalThis.location)) { - try { - loading = true; - const response = await oauth.link(globalThis.location); - authManager.setUser(response); - toastManager.primary($t('linked_oauth_account')); - } catch (error) { - handleError(error, $t('errors.unable_to_link_oauth_account')); - } finally { - await goto('?open=oauth'); - } - } - - loading = false; - }); - const handleUnlink = async () => { try { const response = await oauth.unlink(); @@ -42,15 +23,11 @@
- {#if loading} -
- -
- {:else if featureFlagsManager.value.oauth} + {#if featureFlagsManager.value.oauth} {#if authManager.user.oauthId} {:else} - {/if} diff --git a/web/src/routes/auth/link/+page.svelte b/web/src/routes/auth/link/+page.svelte new file mode 100644 index 0000000000..bf3d6e3a88 --- /dev/null +++ b/web/src/routes/auth/link/+page.svelte @@ -0,0 +1,77 @@ + + + + + {#if featureFlagsManager.value.passwordLogin} + + {$t('oauth_link_existing_account')} + + +
+ {#if errorMessage} + + {/if} + + + + + + + + + + + + {:else} + + {$t('oauth_link_password_login_required')} + + {/if} +
+
diff --git a/web/src/routes/auth/link/+page.ts b/web/src/routes/auth/link/+page.ts new file mode 100644 index 0000000000..d37d0d7cc7 --- /dev/null +++ b/web/src/routes/auth/link/+page.ts @@ -0,0 +1,16 @@ +import { getFormatter } from '$lib/utils/i18n'; +import type { PageLoad } from './$types'; + +export const load = (async ({ url }) => { + const linkToken = url.searchParams.get('linkToken') || ''; + const email = url.searchParams.get('email') || ''; + + const $t = await getFormatter(); + return { + meta: { + title: $t('link_to_oauth'), + }, + linkToken, + email, + }; +}) satisfies PageLoad; diff --git a/web/src/routes/auth/login/+page.svelte b/web/src/routes/auth/login/+page.svelte index b42b16b79c..952657a35b 100644 --- a/web/src/routes/auth/login/+page.svelte +++ b/web/src/routes/auth/login/+page.svelte @@ -1,14 +1,16 @@