mirror of
https://github.com/immich-app/immich.git
synced 2026-04-28 12:00:39 -04:00
feat: oauth re-link via admin-provided token
This commit is contained in:
parent
2da2bef777
commit
00f83e7c66
@ -1126,6 +1126,7 @@
|
||||
"unable_to_hide_person": "Unable to hide person",
|
||||
"unable_to_link_motion_video": "Unable to link motion video",
|
||||
"unable_to_link_oauth_account": "Unable to link OAuth account",
|
||||
"invalid_oauth_relink_token": "This OAuth re-link token is invalid or has expired",
|
||||
"unable_to_log_out_all_devices": "Unable to log out all devices",
|
||||
"unable_to_log_out_device": "Unable to log out device",
|
||||
"unable_to_login_with_oauth": "Unable to login with OAuth",
|
||||
@ -1643,7 +1644,10 @@
|
||||
"notifications": "Notifications",
|
||||
"notifications_setting_description": "Manage notifications",
|
||||
"oauth": "OAuth",
|
||||
"oauth_account_is_linked": "This account is linked to an OAuth identity. Logging in via OAuth will sign you in directly.",
|
||||
"oauth_account_not_linked": "Link this account to an OAuth identity to sign in via your identity provider.",
|
||||
"oauth_link_existing_account": "Log in with your Immich password to link your OAuth account",
|
||||
"oauth_relink_in_progress": "Redirecting to your identity provider to complete the re-link...",
|
||||
"oauth_link_password_login_required": "An account with this email already exists but 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",
|
||||
|
||||
@ -1285,6 +1285,59 @@
|
||||
"x-immich-state": "Stable"
|
||||
}
|
||||
},
|
||||
"/admin/users/{id}/oauth-relink-token": {
|
||||
"post": {
|
||||
"description": "Create a single-use token that lets a user re-link their account to a new OAuth sub (e.g. when migrating IdPs). Deliver the token to the user out-of-band.",
|
||||
"operationId": "createOAuthReLinkTokenAdmin",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"format": "uuid",
|
||||
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"201": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/OAuthReLinkTokenResponseDto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
},
|
||||
{
|
||||
"cookie": []
|
||||
},
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
],
|
||||
"summary": "Issue an OAuth re-link token",
|
||||
"tags": [
|
||||
"Users (admin)"
|
||||
],
|
||||
"x-immich-admin-only": true,
|
||||
"x-immich-history": [
|
||||
{
|
||||
"version": "v2",
|
||||
"state": "Added"
|
||||
}
|
||||
],
|
||||
"x-immich-permission": "adminUser.update"
|
||||
}
|
||||
},
|
||||
"/admin/users/{id}/preferences": {
|
||||
"get": {
|
||||
"description": "Retrieve the preferences of a specific user.",
|
||||
@ -7499,6 +7552,38 @@
|
||||
"x-immich-state": "Stable"
|
||||
}
|
||||
},
|
||||
"/oauth/relink-start": {
|
||||
"post": {
|
||||
"description": "Redeem an admin-issued OAuth re-link token, setting a short-lived cookie that gets consumed by the subsequent OAuth callback.",
|
||||
"operationId": "startOAuthReLink",
|
||||
"parameters": [],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/OAuthReLinkStartDto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"summary": "Start OAuth re-link",
|
||||
"tags": [
|
||||
"Authentication"
|
||||
],
|
||||
"x-immich-history": [
|
||||
{
|
||||
"version": "v2",
|
||||
"state": "Added"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/oauth/unlink": {
|
||||
"post": {
|
||||
"description": "Unlink the OAuth account from the authenticated user.",
|
||||
@ -19086,6 +19171,38 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"OAuthReLinkStartDto": {
|
||||
"properties": {
|
||||
"token": {
|
||||
"description": "Plaintext OAuth re-link token issued by an administrator",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"token"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"OAuthReLinkTokenResponseDto": {
|
||||
"properties": {
|
||||
"expiresAt": {
|
||||
"description": "Token expiration",
|
||||
"example": "2024-01-01T00:00:00.000Z",
|
||||
"format": "date-time",
|
||||
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
|
||||
"type": "string"
|
||||
},
|
||||
"token": {
|
||||
"description": "Single-use token; deliver to the user via /auth/link?token=<token>",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"expiresAt",
|
||||
"token"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"OAuthTokenEndpointAuthMethod": {
|
||||
"description": "OAuth token endpoint auth method",
|
||||
"enum": [
|
||||
|
||||
@ -262,6 +262,12 @@ export type UserAdminUpdateDto = {
|
||||
/** Storage label */
|
||||
storageLabel?: string | null;
|
||||
};
|
||||
export type OAuthReLinkTokenResponseDto = {
|
||||
/** Token expiration */
|
||||
expiresAt: string;
|
||||
/** Single-use token; deliver to the user via /auth/link?token=<token> */
|
||||
token: string;
|
||||
};
|
||||
export type AlbumsResponse = {
|
||||
defaultAssetOrder: AssetOrder;
|
||||
};
|
||||
@ -1421,6 +1427,10 @@ export type OAuthCallbackDto = {
|
||||
/** OAuth callback URL */
|
||||
url: string;
|
||||
};
|
||||
export type OAuthReLinkStartDto = {
|
||||
/** Plaintext OAuth re-link token issued by an administrator */
|
||||
token: string;
|
||||
};
|
||||
export type PartnerResponseDto = {
|
||||
avatarColor: UserAvatarColor;
|
||||
/** User email */
|
||||
@ -3527,6 +3537,20 @@ export function updateUserAdmin({ id, userAdminUpdateDto }: {
|
||||
body: userAdminUpdateDto
|
||||
})));
|
||||
}
|
||||
/**
|
||||
* Issue an OAuth re-link token
|
||||
*/
|
||||
export function createOAuthReLinkTokenAdmin({ id }: {
|
||||
id: string;
|
||||
}, opts?: Oazapfts.RequestOpts) {
|
||||
return oazapfts.ok(oazapfts.fetchJson<{
|
||||
status: 201;
|
||||
data: OAuthReLinkTokenResponseDto;
|
||||
}>(`/admin/users/${encodeURIComponent(id)}/oauth-relink-token`, {
|
||||
...opts,
|
||||
method: "POST"
|
||||
}));
|
||||
}
|
||||
/**
|
||||
* Retrieve user preferences
|
||||
*/
|
||||
@ -4966,6 +4990,18 @@ export function redirectOAuthToMobile(opts?: Oazapfts.RequestOpts) {
|
||||
...opts
|
||||
}));
|
||||
}
|
||||
/**
|
||||
* Start OAuth re-link
|
||||
*/
|
||||
export function startOAuthReLink({ oAuthReLinkStartDto }: {
|
||||
oAuthReLinkStartDto: OAuthReLinkStartDto;
|
||||
}, opts?: Oazapfts.RequestOpts) {
|
||||
return oazapfts.ok(oazapfts.fetchText("/oauth/relink-start", oazapfts.json({
|
||||
...opts,
|
||||
method: "POST",
|
||||
body: oAuthReLinkStartDto
|
||||
})));
|
||||
}
|
||||
/**
|
||||
* Unlink OAuth account
|
||||
*/
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { Body, Controller, Get, HttpCode, HttpStatus, Post, Redirect, Req, Res } from '@nestjs/common';
|
||||
import { ApiConsumes, ApiTags } from '@nestjs/swagger';
|
||||
import { parse as parseCookie } from 'cookie';
|
||||
import { Request, Response } from 'express';
|
||||
import { Endpoint, HistoryBuilder } from 'src/decorators';
|
||||
import {
|
||||
@ -9,6 +10,7 @@ import {
|
||||
OAuthBackchannelLogoutDto,
|
||||
OAuthCallbackDto,
|
||||
OAuthConfigDto,
|
||||
OAuthReLinkStartDto,
|
||||
} from 'src/dtos/auth.dto';
|
||||
import { UserAdminResponseDto } from 'src/dtos/user.dto';
|
||||
import { ApiTag, AuthType, ImmichCookie } from 'src/enum';
|
||||
@ -73,6 +75,8 @@ export class OAuthController {
|
||||
@Body() dto: OAuthCallbackDto,
|
||||
@GetLoginDetails() loginDetails: LoginDetails,
|
||||
): Promise<LoginResponseDto> {
|
||||
const hadLinkCookie = !!parseCookie(request.headers.cookie || '')[ImmichCookie.OAuthLinkToken];
|
||||
let freshLinkCookieIssued = false;
|
||||
try {
|
||||
const body = await this.service.callback(dto, request.headers, loginDetails);
|
||||
return respondWithCookie(res, body, {
|
||||
@ -89,14 +93,38 @@ export class OAuthController {
|
||||
isSecure: loginDetails.isSecure,
|
||||
values: [{ key: ImmichCookie.OAuthLinkToken, value: error.oauthLinkToken }],
|
||||
});
|
||||
freshLinkCookieIssued = true;
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
res.clearCookie(ImmichCookie.OAuthState);
|
||||
res.clearCookie(ImmichCookie.OAuthCodeVerifier);
|
||||
if (hadLinkCookie && !freshLinkCookieIssued) {
|
||||
res.clearCookie(ImmichCookie.OAuthLinkToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Post('relink-start')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@Endpoint({
|
||||
summary: 'Start OAuth re-link',
|
||||
description:
|
||||
'Redeem an admin-issued OAuth re-link token, setting a short-lived cookie that gets consumed by the subsequent OAuth callback.',
|
||||
history: new HistoryBuilder().added('v2'),
|
||||
})
|
||||
async startOAuthReLink(
|
||||
@Body() dto: OAuthReLinkStartDto,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
@GetLoginDetails() loginDetails: LoginDetails,
|
||||
): Promise<void> {
|
||||
await this.service.validateOAuthReLinkToken(dto.token);
|
||||
respondWithCookie(res, null, {
|
||||
isSecure: loginDetails.isSecure,
|
||||
values: [{ key: ImmichCookie.OAuthLinkToken, value: dto.token }],
|
||||
});
|
||||
}
|
||||
|
||||
@Post('unlink')
|
||||
@Authenticated()
|
||||
@HttpCode(HttpStatus.OK)
|
||||
|
||||
@ -6,6 +6,7 @@ import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { SessionResponseDto } from 'src/dtos/session.dto';
|
||||
import { UserPreferencesResponseDto, UserPreferencesUpdateDto } from 'src/dtos/user-preferences.dto';
|
||||
import {
|
||||
OAuthReLinkTokenResponseDto,
|
||||
UserAdminCreateDto,
|
||||
UserAdminDeleteDto,
|
||||
UserAdminResponseDto,
|
||||
@ -137,6 +138,21 @@ export class UserAdminController {
|
||||
return this.service.updatePreferences(auth, id, dto);
|
||||
}
|
||||
|
||||
@Post(':id/oauth-relink-token')
|
||||
@Authenticated({ permission: Permission.AdminUserUpdate, admin: true })
|
||||
@Endpoint({
|
||||
summary: 'Issue an OAuth re-link token',
|
||||
description:
|
||||
'Create a single-use token that lets a user re-link their account to a new OAuth sub (e.g. when migrating IdPs). Deliver the token to the user out-of-band.',
|
||||
history: new HistoryBuilder().added('v2'),
|
||||
})
|
||||
createOAuthReLinkTokenAdmin(
|
||||
@Auth() auth: AuthDto,
|
||||
@Param() { id }: UUIDParamDto,
|
||||
): Promise<OAuthReLinkTokenResponseDto> {
|
||||
return this.service.createOAuthReLinkToken(auth, id);
|
||||
}
|
||||
|
||||
@Post(':id/restore')
|
||||
@Authenticated({ permission: Permission.AdminUserDelete, admin: true })
|
||||
@HttpCode(HttpStatus.OK)
|
||||
|
||||
@ -128,6 +128,12 @@ const OAuthBackchannelLogoutSchema = z
|
||||
.object({ logout_token: z.string().describe('OAuth logout token') })
|
||||
.meta({ id: 'OAuthBackchannelLogoutDto' });
|
||||
|
||||
const OAuthReLinkStartSchema = z
|
||||
.object({
|
||||
token: z.string().describe('Plaintext OAuth re-link token issued by an administrator'),
|
||||
})
|
||||
.meta({ id: 'OAuthReLinkStartDto' });
|
||||
|
||||
const AuthStatusResponseSchema = z
|
||||
.object({
|
||||
pinCode: z.boolean().describe('Has PIN code set'),
|
||||
@ -152,4 +158,5 @@ export class OAuthCallbackDto extends createZodDto(OAuthCallbackSchema) {}
|
||||
export class OAuthConfigDto extends createZodDto(OAuthConfigSchema) {}
|
||||
export class OAuthAuthorizeResponseDto extends createZodDto(OAuthAuthorizeResponseSchema) {}
|
||||
export class OAuthBackchannelLogoutDto extends createZodDto(OAuthBackchannelLogoutSchema) {}
|
||||
export class OAuthReLinkStartDto extends createZodDto(OAuthReLinkStartSchema) {}
|
||||
export class AuthStatusResponseDto extends createZodDto(AuthStatusResponseSchema) {}
|
||||
|
||||
@ -121,6 +121,15 @@ const UserAdminDeleteSchema = z
|
||||
|
||||
export class UserAdminDeleteDto extends createZodDto(UserAdminDeleteSchema) {}
|
||||
|
||||
const OAuthReLinkTokenResponseSchema = z
|
||||
.object({
|
||||
token: z.string().describe('Single-use token; deliver to the user via /auth/link?token=<token>'),
|
||||
expiresAt: isoDatetimeToDate.describe('Token expiration'),
|
||||
})
|
||||
.meta({ id: 'OAuthReLinkTokenResponseDto' });
|
||||
|
||||
export class OAuthReLinkTokenResponseDto extends createZodDto(OAuthReLinkTokenResponseSchema) {}
|
||||
|
||||
const UserAdminResponseSchema = UserResponseSchema.extend({
|
||||
storageLabel: z.string().nullable().describe('Storage label'),
|
||||
shouldChangePassword: z.boolean().describe('Require password change on next login'),
|
||||
|
||||
@ -13,15 +13,28 @@ export class OAuthLinkTokenRepository {
|
||||
return this.db.insertInto('oauth_link_token').values(dto).returningAll().executeTakeFirstOrThrow();
|
||||
}
|
||||
|
||||
consumeToken(token: Buffer) {
|
||||
getByToken(token: Buffer) {
|
||||
return this.db
|
||||
.deleteFrom('oauth_link_token')
|
||||
.selectFrom('oauth_link_token')
|
||||
.selectAll()
|
||||
.where('token', '=', token)
|
||||
.where('expiresAt', '>', DateTime.now().toJSDate())
|
||||
.returningAll()
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
consumeToken(token: Buffer, kind: 'callback' | 'admin' | 'any' = 'any') {
|
||||
let query = this.db
|
||||
.deleteFrom('oauth_link_token')
|
||||
.where('token', '=', token)
|
||||
.where('expiresAt', '>', DateTime.now().toJSDate());
|
||||
if (kind === 'callback') {
|
||||
query = query.where('oauthSub', 'is not', null);
|
||||
} else if (kind === 'admin') {
|
||||
query = query.where('oauthSub', 'is', null);
|
||||
}
|
||||
return query.returningAll().executeTakeFirst();
|
||||
}
|
||||
|
||||
async cleanup() {
|
||||
const result = await this.db
|
||||
.deleteFrom('oauth_link_token')
|
||||
|
||||
@ -5,10 +5,10 @@ export async function up(db: Kysely<any>): Promise<void> {
|
||||
CREATE TABLE "oauth_link_token" (
|
||||
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"token" bytea NOT NULL,
|
||||
"oauthSub" varchar NOT NULL,
|
||||
"oauthSub" varchar,
|
||||
"oauthSid" varchar,
|
||||
"email" varchar NOT NULL,
|
||||
"profile" jsonb NOT NULL,
|
||||
"profile" jsonb,
|
||||
"expiresAt" timestamp with time zone NOT NULL,
|
||||
"createdAt" timestamp with time zone NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
@ -16,8 +16,8 @@ export class OAuthLinkTokenTable {
|
||||
@Column({ type: 'bytea', index: true })
|
||||
token!: Buffer;
|
||||
|
||||
@Column()
|
||||
oauthSub!: string;
|
||||
@Column({ nullable: true })
|
||||
oauthSub!: string | null;
|
||||
|
||||
@Column({ nullable: true })
|
||||
oauthSid!: string | null;
|
||||
@ -25,8 +25,8 @@ export class OAuthLinkTokenTable {
|
||||
@Column()
|
||||
email!: string;
|
||||
|
||||
@Column({ type: 'jsonb' })
|
||||
profile!: OAuthLinkTokenProfile;
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
profile!: OAuthLinkTokenProfile | null;
|
||||
|
||||
@Column({ type: 'timestamp with time zone' })
|
||||
expiresAt!: Timestamp;
|
||||
|
||||
@ -1355,6 +1355,145 @@ describe(AuthService.name, () => {
|
||||
expect.objectContaining({ profile: expect.objectContaining({ isAdmin: true }) }),
|
||||
);
|
||||
});
|
||||
|
||||
describe('admin-issued re-link token', () => {
|
||||
const reLinkRecord = {
|
||||
id: 'token-id',
|
||||
oauthSub: null,
|
||||
oauthSid: 'idp-sid-new',
|
||||
email: 'linked@immich.cloud',
|
||||
profile: null,
|
||||
token: Buffer.from('hashed'),
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
it('should relink to the user identified by the token when the new sub is unknown', async () => {
|
||||
const targetUser = UserFactory.create({ email: 'linked@immich.cloud', oauthId: 'old-sub' });
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
|
||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
|
||||
profile: OAuthProfileFactory.create({ sub: 'new-sub' }),
|
||||
sid: 'idp-sid-new',
|
||||
});
|
||||
mocks.user.getByOAuthId.mockResolvedValueOnce(void 0).mockResolvedValueOnce(void 0);
|
||||
mocks.oauthLinkToken.consumeToken.mockResolvedValue(reLinkRecord);
|
||||
mocks.user.getByEmail.mockResolvedValue(targetUser);
|
||||
mocks.user.update.mockResolvedValue({ ...targetUser, oauthId: 'new-sub' });
|
||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||
|
||||
await sut.callback(
|
||||
{ url: 'http://immich/auth/link?code=abc', state: 'xyz', codeVerifier: 'foo' },
|
||||
{ cookie: 'immich_oauth_link_token=plain' },
|
||||
loginDetails,
|
||||
);
|
||||
|
||||
expect(mocks.user.update).toHaveBeenCalledWith(targetUser.id, { oauthId: 'new-sub' });
|
||||
expect(mocks.session.create).toHaveBeenCalledWith(expect.objectContaining({ oauthSid: 'idp-sid-new' }));
|
||||
});
|
||||
|
||||
it('should reject when the token email no longer matches a user', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
|
||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
|
||||
profile: OAuthProfileFactory.create({ sub: 'new-sub' }),
|
||||
});
|
||||
mocks.user.getByOAuthId.mockResolvedValue(void 0);
|
||||
mocks.oauthLinkToken.consumeToken.mockResolvedValue(reLinkRecord);
|
||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||
|
||||
await expect(
|
||||
sut.callback(
|
||||
{ url: 'http://immich/auth/link?code=abc', state: 'xyz', codeVerifier: 'foo' },
|
||||
{ cookie: 'immich_oauth_link_token=plain' },
|
||||
loginDetails,
|
||||
),
|
||||
).rejects.toThrow('no longer exists');
|
||||
});
|
||||
|
||||
it('should reject when the new sub is already linked to a different user', async () => {
|
||||
const targetUser = UserFactory.create({ email: 'linked@immich.cloud' });
|
||||
const other = UserFactory.create({ oauthId: 'new-sub' });
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
|
||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
|
||||
profile: OAuthProfileFactory.create({ sub: 'new-sub' }),
|
||||
});
|
||||
mocks.user.getByOAuthId.mockResolvedValueOnce(void 0).mockResolvedValueOnce(other);
|
||||
mocks.oauthLinkToken.consumeToken.mockResolvedValue(reLinkRecord);
|
||||
mocks.user.getByEmail.mockResolvedValue(targetUser);
|
||||
|
||||
await expect(
|
||||
sut.callback(
|
||||
{ url: 'http://immich/auth/link?code=abc', state: 'xyz', codeVerifier: 'foo' },
|
||||
{ cookie: 'immich_oauth_link_token=plain' },
|
||||
loginDetails,
|
||||
),
|
||||
).rejects.toThrow('already been linked to another user');
|
||||
});
|
||||
|
||||
it('should fall through to callback-issued link flow when the cookie is not an admin-issued token', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
|
||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
|
||||
profile: OAuthProfileFactory.create({ sub: 'new-sub' }),
|
||||
});
|
||||
mocks.user.getByOAuthId.mockResolvedValue(void 0);
|
||||
// Cookie carries a callback-issued token; admin-typed consume returns nothing.
|
||||
mocks.oauthLinkToken.consumeToken.mockResolvedValue(void 0);
|
||||
mocks.oauthLinkToken.create.mockResolvedValue({} as any);
|
||||
|
||||
await expect(
|
||||
sut.callback(
|
||||
{ url: 'http://immich/auth/link?code=abc', state: 'xyz', codeVerifier: 'foo' },
|
||||
{ cookie: 'immich_oauth_link_token=plain' },
|
||||
loginDetails,
|
||||
),
|
||||
).rejects.toThrow(OAuthLinkRequiredException);
|
||||
|
||||
expect(mocks.user.update).not.toHaveBeenCalled();
|
||||
expect(mocks.oauthLinkToken.create).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateOAuthReLinkToken', () => {
|
||||
it('should throw when OAuth is disabled', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.disabled);
|
||||
await expect(sut.validateOAuthReLinkToken('plain')).rejects.toThrow('OAuth is not enabled');
|
||||
});
|
||||
|
||||
it('should throw when the token does not exist', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
|
||||
mocks.oauthLinkToken.getByToken.mockResolvedValue(void 0);
|
||||
await expect(sut.validateOAuthReLinkToken('plain')).rejects.toThrow('Invalid or expired');
|
||||
});
|
||||
|
||||
it('should throw when the token is a callback-issued one (non-null sub)', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
|
||||
mocks.oauthLinkToken.getByToken.mockResolvedValue({
|
||||
id: 'token-id',
|
||||
oauthSub: 'sub',
|
||||
oauthSid: null,
|
||||
email: 'e',
|
||||
profile: null,
|
||||
token: Buffer.from('hashed'),
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
createdAt: new Date(),
|
||||
});
|
||||
await expect(sut.validateOAuthReLinkToken('plain')).rejects.toThrow('Invalid or expired');
|
||||
});
|
||||
|
||||
it('should resolve when the token is valid and admin-issued', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
|
||||
mocks.oauthLinkToken.getByToken.mockResolvedValue({
|
||||
id: 'token-id',
|
||||
oauthSub: null,
|
||||
oauthSid: null,
|
||||
email: 'e',
|
||||
profile: null,
|
||||
token: Buffer.from('hashed'),
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
createdAt: new Date(),
|
||||
});
|
||||
await expect(sut.validateOAuthReLinkToken('plain')).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('unlink', () => {
|
||||
|
||||
@ -91,13 +91,13 @@ export class AuthService extends BaseService {
|
||||
const linkTokenCookie = this.getCookieOAuthLinkToken(headers);
|
||||
if (linkTokenCookie) {
|
||||
const hashedToken = this.cryptoRepository.hashSha256(linkTokenCookie);
|
||||
const record = await this.oauthLinkTokenRepository.consumeToken(hashedToken);
|
||||
if (record) {
|
||||
const record = await this.oauthLinkTokenRepository.consumeToken(hashedToken, 'callback');
|
||||
if (record && record.oauthSub !== null && record.profile !== null) {
|
||||
const duplicate = await this.userRepository.getByOAuthId(record.oauthSub);
|
||||
if (duplicate && duplicate.id !== user.id) {
|
||||
throw new BadRequestException('This OAuth account has already been linked to another user.');
|
||||
}
|
||||
user = await this.applyOAuthProfileToUser(user, record);
|
||||
user = await this.applyOAuthProfileToUser(user, { oauthSub: record.oauthSub, profile: record.profile });
|
||||
linkedOAuthSid = record.oauthSid ?? undefined;
|
||||
}
|
||||
}
|
||||
@ -116,19 +116,25 @@ export class AuthService extends BaseService {
|
||||
throw new BadRequestException('Missing OAuth link token');
|
||||
}
|
||||
|
||||
const record = await this.consumeOAuthLinkToken(linkTokenCookie);
|
||||
const existing = await this.userRepository.getByOAuthId(record.oauthSub);
|
||||
const hashedToken = this.cryptoRepository.hashSha256(linkTokenCookie);
|
||||
const record = await this.oauthLinkTokenRepository.consumeToken(hashedToken, 'callback');
|
||||
if (!record || record.oauthSub === null || record.profile === null) {
|
||||
throw new BadRequestException('Invalid OAuth link token for registration');
|
||||
}
|
||||
const { oauthSub, profile } = record;
|
||||
|
||||
const existing = await this.userRepository.getByOAuthId(oauthSub);
|
||||
if (existing) {
|
||||
throw new BadRequestException('This OAuth account has already been linked to another user.');
|
||||
}
|
||||
|
||||
this.logger.log(`Registering new user from OAuth: ${record.oauthSub}/${record.email}`);
|
||||
this.logger.log(`Registering new user from OAuth: ${oauthSub}/${record.email}`);
|
||||
const newUser = await this.createUser({
|
||||
email: record.email,
|
||||
name: record.profile.name,
|
||||
isAdmin: record.profile.isAdmin,
|
||||
name: profile.name,
|
||||
isAdmin: profile.isAdmin,
|
||||
});
|
||||
const user = await this.applyOAuthProfileToUser(newUser, record);
|
||||
const user = await this.applyOAuthProfileToUser(newUser, { oauthSub, profile });
|
||||
return this.createLoginResponse(user, details, record.oauthSid ?? undefined);
|
||||
}
|
||||
|
||||
@ -331,6 +337,19 @@ export class AuthService extends BaseService {
|
||||
return `${MOBILE_REDIRECT}?${url.split('?')[1] || ''}`;
|
||||
}
|
||||
|
||||
async validateOAuthReLinkToken(plainToken: string) {
|
||||
const { oauth } = await this.getConfig({ withCache: false });
|
||||
if (!oauth.enabled) {
|
||||
throw new BadRequestException('OAuth is not enabled');
|
||||
}
|
||||
|
||||
const hashed = this.cryptoRepository.hashSha256(plainToken);
|
||||
const record = await this.oauthLinkTokenRepository.getByToken(hashed);
|
||||
if (!record || record.oauthSub !== null) {
|
||||
throw new BadRequestException('Invalid or expired re-link token');
|
||||
}
|
||||
}
|
||||
|
||||
async authorize(dto: OAuthConfigDto) {
|
||||
const { oauth } = await this.getConfig({ withCache: false });
|
||||
|
||||
@ -380,6 +399,15 @@ export class AuthService extends BaseService {
|
||||
return this.createLoginResponse(user, loginDetails, oauthSid);
|
||||
}
|
||||
|
||||
const reLinkTokenCookie = this.getCookieOAuthLinkToken(headers);
|
||||
if (reLinkTokenCookie) {
|
||||
const hashedCookie = this.cryptoRepository.hashSha256(reLinkTokenCookie);
|
||||
const record = await this.oauthLinkTokenRepository.consumeToken(hashedCookie, 'admin');
|
||||
if (record) {
|
||||
return this.completeAdminIssuedReLink(record, profile.sub, oauthSid, loginDetails);
|
||||
}
|
||||
}
|
||||
|
||||
if (!normalizedEmail) {
|
||||
throw new BadRequestException('OAuth profile does not have an email address');
|
||||
}
|
||||
@ -398,6 +426,27 @@ export class AuthService extends BaseService {
|
||||
throw new OAuthLinkRequiredException(normalizedEmail, plainToken);
|
||||
}
|
||||
|
||||
private async completeAdminIssuedReLink(
|
||||
record: { email: string },
|
||||
newOAuthSub: string,
|
||||
oauthSid: string | undefined,
|
||||
loginDetails: LoginDetails,
|
||||
) {
|
||||
const targetUser = await this.userRepository.getByEmail(record.email);
|
||||
if (!targetUser) {
|
||||
throw new BadRequestException('The user for this re-link token no longer exists');
|
||||
}
|
||||
|
||||
const duplicate = await this.userRepository.getByOAuthId(newOAuthSub);
|
||||
if (duplicate && duplicate.id !== targetUser.id) {
|
||||
throw new BadRequestException('This OAuth account has already been linked to another user.');
|
||||
}
|
||||
|
||||
this.logger.log(`Completing admin-issued OAuth re-link for user ${targetUser.id}`);
|
||||
const updated = await this.userRepository.update(targetUser.id, { oauthId: newOAuthSub });
|
||||
return this.createLoginResponse(updated, loginDetails, oauthSid);
|
||||
}
|
||||
|
||||
private resolveOAuthProfile(
|
||||
profile: OAuthProfile,
|
||||
normalizedEmail: string,
|
||||
@ -433,15 +482,6 @@ export class AuthService extends BaseService {
|
||||
};
|
||||
}
|
||||
|
||||
private async consumeOAuthLinkToken(plainToken: string) {
|
||||
const hashedToken = this.cryptoRepository.hashSha256(plainToken);
|
||||
const record = await this.oauthLinkTokenRepository.consumeToken(hashedToken);
|
||||
if (!record) {
|
||||
throw new BadRequestException('Invalid or expired link token');
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
private async applyOAuthProfileToUser(user: UserAdmin, record: { oauthSub: string; profile: OAuthLinkTokenProfile }) {
|
||||
const { profile } = record;
|
||||
const storageLabel = profile.storageLabel ? sanitize(profile.storageLabel.replaceAll('.', '')) : null;
|
||||
|
||||
@ -5,6 +5,7 @@ import { UserAdminService } from 'src/services/user-admin.service';
|
||||
import { AuthFactory } from 'test/factories/auth.factory';
|
||||
import { UserFactory } from 'test/factories/user.factory';
|
||||
import { authStub } from 'test/fixtures/auth.stub';
|
||||
import { systemConfigStub } from 'test/fixtures/system-config.stub';
|
||||
import { userStub } from 'test/fixtures/user.stub';
|
||||
import { newTestService, ServiceMocks } from 'test/utils';
|
||||
import { describe } from 'vitest';
|
||||
@ -165,6 +166,42 @@ describe(UserAdminService.name, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('createOAuthReLinkToken', () => {
|
||||
it('should throw when OAuth is not enabled', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.disabled);
|
||||
await expect(sut.createOAuthReLinkToken(authStub.admin, userStub.user1.id)).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
expect(mocks.oauthLinkToken.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw when the target user is missing', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
|
||||
mocks.user.get.mockResolvedValueOnce(void 0);
|
||||
await expect(sut.createOAuthReLinkToken(authStub.admin, 'missing')).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(mocks.oauthLinkToken.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should create a token with null oauthSub and the target email', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
|
||||
mocks.oauthLinkToken.create.mockResolvedValue({} as any);
|
||||
|
||||
const result = await sut.createOAuthReLinkToken(authStub.admin, userStub.user1.id);
|
||||
|
||||
expect(mocks.oauthLinkToken.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
oauthSub: null,
|
||||
oauthSid: null,
|
||||
profile: null,
|
||||
email: userStub.user1.email,
|
||||
}),
|
||||
);
|
||||
expect(result.token).toEqual(expect.any(String));
|
||||
expect(result.expiresAt).toBeInstanceOf(Date);
|
||||
expect(result.expiresAt.getTime()).toBeGreaterThan(Date.now());
|
||||
});
|
||||
});
|
||||
|
||||
describe('restore', () => {
|
||||
it('should throw error if user could not be found', async () => {
|
||||
mocks.user.get.mockResolvedValue(void 0);
|
||||
|
||||
@ -1,10 +1,12 @@
|
||||
import { BadRequestException, ForbiddenException, Injectable } from '@nestjs/common';
|
||||
import { DateTime } from 'luxon';
|
||||
import { SALT_ROUNDS } from 'src/constants';
|
||||
import { AssetStatsDto, AssetStatsResponseDto, mapStats } from 'src/dtos/asset.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { SessionResponseDto, mapSession } from 'src/dtos/session.dto';
|
||||
import { UserPreferencesResponseDto, UserPreferencesUpdateDto, mapPreferences } from 'src/dtos/user-preferences.dto';
|
||||
import {
|
||||
OAuthReLinkTokenResponseDto,
|
||||
UserAdminCreateDto,
|
||||
UserAdminDeleteDto,
|
||||
UserAdminResponseDto,
|
||||
@ -137,6 +139,29 @@ export class UserAdminService extends BaseService {
|
||||
return mapPreferences(getPreferences(metadata));
|
||||
}
|
||||
|
||||
async createOAuthReLinkToken(auth: AuthDto, id: string): Promise<OAuthReLinkTokenResponseDto> {
|
||||
const { oauth } = await this.getConfig({ withCache: false });
|
||||
if (!oauth.enabled) {
|
||||
throw new BadRequestException('OAuth is not enabled');
|
||||
}
|
||||
|
||||
const user = await this.findOrFail(id, {});
|
||||
const plainToken = this.cryptoRepository.randomBytesAsText(32);
|
||||
const hashedToken = this.cryptoRepository.hashSha256(plainToken);
|
||||
const expiresAt = DateTime.now().plus({ hours: 24 }).toJSDate();
|
||||
await this.oauthLinkTokenRepository.create({
|
||||
token: hashedToken,
|
||||
oauthSub: null,
|
||||
oauthSid: null,
|
||||
email: user.email,
|
||||
profile: null,
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
this.logger.log(`Admin ${auth.user.id} issued an OAuth re-link token for user ${user.id}`);
|
||||
return { token: plainToken, expiresAt };
|
||||
}
|
||||
|
||||
async updatePreferences(auth: AuthDto, id: string, dto: UserPreferencesUpdateDto) {
|
||||
await this.findOrFail(id, { withDeleted: false });
|
||||
const metadata = await this.userRepository.getMetadata(id);
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
import { Route } from '$lib/route';
|
||||
import { oauth } from '$lib/utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { Button, toastManager } from '@immich/ui';
|
||||
import { Button, Stack, Text, toastManager } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
@ -20,18 +20,28 @@
|
||||
};
|
||||
</script>
|
||||
|
||||
<section class="my-4">
|
||||
<div in:fade={{ duration: 500 }}>
|
||||
<div class="sm:ms-8 flex justify-end">
|
||||
{#if featureFlagsManager.value.oauth}
|
||||
{#if featureFlagsManager.value.oauth}
|
||||
<section class="my-4">
|
||||
<div in:fade={{ duration: 500 }}>
|
||||
<Stack gap={3}>
|
||||
{#if authManager.user.oauthId}
|
||||
<Button shape="round" size="small" onclick={() => handleUnlink()}>{$t('unlink_oauth')}</Button>
|
||||
<Text>{$t('oauth_account_is_linked')}</Text>
|
||||
{#if featureFlagsManager.value.passwordLogin}
|
||||
<div class="sm:ms-8 flex justify-end">
|
||||
<Button shape="round" size="small" color="danger" onclick={() => handleUnlink()}>
|
||||
{$t('unlink_oauth')}
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<Button shape="round" size="small" onclick={() => goto(Route.login({ autoLaunch: 1 }))}
|
||||
>{$t('link_to_oauth')}</Button
|
||||
>
|
||||
<Text>{$t('oauth_account_not_linked')}</Text>
|
||||
<div class="sm:ms-8 flex justify-end">
|
||||
<Button shape="round" size="small" onclick={() => goto(Route.login({ autoLaunch: 1 }))}>
|
||||
{$t('link_to_oauth')}
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</Stack>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
@ -5,9 +5,11 @@
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||
import { Route } from '$lib/route';
|
||||
import { oauth } from '$lib/utils';
|
||||
import { getServerErrorMessage, handleError } from '$lib/utils/handle-error';
|
||||
import { login, register } from '@immich/sdk';
|
||||
import { isHttpError, login, register, startOAuthReLink } from '@immich/sdk';
|
||||
import { Alert, Button, Field, Input, PasswordInput, Stack, toastManager } from '@immich/ui';
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
@ -22,6 +24,41 @@
|
||||
let errorMessage = $state('');
|
||||
let loading = $state(false);
|
||||
let registering = $state(false);
|
||||
let reLinkMode = $state(!!data.reLinkToken);
|
||||
let reLinkLoading = $state(!!data.reLinkToken);
|
||||
let reLinkError = $state('');
|
||||
|
||||
onMount(async () => {
|
||||
if (oauth.isCallback(globalThis.location)) {
|
||||
reLinkLoading = true;
|
||||
try {
|
||||
const user = await oauth.login(globalThis.location);
|
||||
eventManager.emit('AuthLogin', user);
|
||||
await authManager.refresh();
|
||||
toastManager.primary($t('linked_oauth_account'));
|
||||
await goto(Route.photos(), { invalidateAll: true });
|
||||
} catch (error) {
|
||||
reLinkLoading = false;
|
||||
reLinkMode = false;
|
||||
reLinkError =
|
||||
getServerErrorMessage(error) ||
|
||||
(isHttpError(error) ? error.message : undefined) ||
|
||||
$t('errors.unable_to_complete_oauth_login');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.reLinkToken) {
|
||||
try {
|
||||
await startOAuthReLink({ oAuthReLinkStartDto: { token: data.reLinkToken } });
|
||||
await oauth.authorize(globalThis.location);
|
||||
} catch (error) {
|
||||
reLinkLoading = false;
|
||||
reLinkMode = false;
|
||||
reLinkError = getServerErrorMessage(error) || $t('errors.invalid_oauth_relink_token');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const handleSubmit = async (event: Event) => {
|
||||
event.preventDefault();
|
||||
@ -55,56 +92,66 @@
|
||||
|
||||
<AuthPageLayout title={data.meta.title}>
|
||||
<Stack gap={4}>
|
||||
{#if featureFlagsManager.value.passwordLogin}
|
||||
<Alert color="primary">
|
||||
{$t('oauth_link_existing_account')}
|
||||
</Alert>
|
||||
|
||||
<form onsubmit={handleSubmit} class="flex flex-col gap-4">
|
||||
{#if errorMessage}
|
||||
<Alert color="danger" title={errorMessage} closable />
|
||||
{/if}
|
||||
|
||||
<Field label={$t('email')}>
|
||||
<Input id="email" name="email" type="email" autocomplete="email" bind:value={email} />
|
||||
</Field>
|
||||
|
||||
<Field label={$t('password')}>
|
||||
<PasswordInput id="password" bind:value={password} autocomplete="current-password" />
|
||||
</Field>
|
||||
|
||||
<Button type="submit" size="large" shape="round" fullWidth {loading} class="mt-6">
|
||||
{$t('to_login')}
|
||||
</Button>
|
||||
</form>
|
||||
{:else}
|
||||
<Alert color="warning">
|
||||
{$t('oauth_link_password_login_required')}
|
||||
</Alert>
|
||||
{#if reLinkError}
|
||||
<Alert color="danger" title={reLinkError} closable />
|
||||
{/if}
|
||||
|
||||
{#if featureFlagsManager.value.oauthAutoRegister}
|
||||
{#if reLinkMode && reLinkLoading}
|
||||
<Alert color="primary">
|
||||
{$t('oauth_relink_in_progress')}
|
||||
</Alert>
|
||||
{:else}
|
||||
{#if featureFlagsManager.value.passwordLogin}
|
||||
<div class="inline-flex w-full items-center justify-center my-4">
|
||||
<hr class="my-4 h-px w-3/4 border-0 bg-gray-200 dark:bg-gray-600" />
|
||||
<span
|
||||
class="absolute start-1/2 -translate-x-1/2 bg-gray-50 px-3 font-medium text-gray-900 dark:bg-neutral-900 dark:text-white uppercase"
|
||||
>
|
||||
{$t('or')}
|
||||
</span>
|
||||
</div>
|
||||
<Alert color="primary">
|
||||
{$t('oauth_link_existing_account')}
|
||||
</Alert>
|
||||
|
||||
<form onsubmit={handleSubmit} class="flex flex-col gap-4">
|
||||
{#if errorMessage}
|
||||
<Alert color="danger" title={errorMessage} closable />
|
||||
{/if}
|
||||
|
||||
<Field label={$t('email')}>
|
||||
<Input id="email" name="email" type="email" autocomplete="email" bind:value={email} />
|
||||
</Field>
|
||||
|
||||
<Field label={$t('password')}>
|
||||
<PasswordInput id="password" bind:value={password} autocomplete="current-password" />
|
||||
</Field>
|
||||
|
||||
<Button type="submit" size="large" shape="round" fullWidth {loading} class="mt-6">
|
||||
{$t('to_login')}
|
||||
</Button>
|
||||
</form>
|
||||
{:else}
|
||||
<Alert color="warning">
|
||||
{$t('oauth_link_password_login_required')}
|
||||
</Alert>
|
||||
{/if}
|
||||
|
||||
<Button
|
||||
shape="round"
|
||||
size="large"
|
||||
fullWidth
|
||||
color={featureFlagsManager.value.passwordLogin ? 'secondary' : 'primary'}
|
||||
loading={registering}
|
||||
onclick={handleRegister}
|
||||
>
|
||||
{$t('create_new_account')}
|
||||
</Button>
|
||||
{#if featureFlagsManager.value.oauthAutoRegister}
|
||||
{#if featureFlagsManager.value.passwordLogin}
|
||||
<div class="inline-flex w-full items-center justify-center my-4">
|
||||
<hr class="my-4 h-px w-3/4 border-0 bg-gray-200 dark:bg-gray-600" />
|
||||
<span
|
||||
class="absolute start-1/2 -translate-x-1/2 bg-gray-50 px-3 font-medium text-gray-900 dark:bg-neutral-900 dark:text-white uppercase"
|
||||
>
|
||||
{$t('or')}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<Button
|
||||
shape="round"
|
||||
size="large"
|
||||
fullWidth
|
||||
color={featureFlagsManager.value.passwordLogin ? 'secondary' : 'primary'}
|
||||
loading={registering}
|
||||
onclick={handleRegister}
|
||||
>
|
||||
{$t('create_new_account')}
|
||||
</Button>
|
||||
{/if}
|
||||
{/if}
|
||||
</Stack>
|
||||
</AuthPageLayout>
|
||||
|
||||
@ -3,6 +3,7 @@ import type { PageLoad } from './$types';
|
||||
|
||||
export const load = (async ({ url }) => {
|
||||
const email = url.searchParams.get('email') || '';
|
||||
const reLinkToken = url.searchParams.get('token') || '';
|
||||
|
||||
const $t = await getFormatter();
|
||||
return {
|
||||
@ -10,5 +11,6 @@ export const load = (async ({ url }) => {
|
||||
title: $t('link_to_oauth'),
|
||||
},
|
||||
email,
|
||||
reLinkToken,
|
||||
};
|
||||
}) satisfies PageLoad;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user