feat: lock auth session (#18322)

This commit is contained in:
Jason Rasmussen 2025-05-15 18:08:31 -04:00 committed by GitHub
parent ecb66fdb2c
commit c1150fe7e3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
37 changed files with 310 additions and 72 deletions

View File

@ -1142,6 +1142,7 @@
"location_picker_latitude_hint": "Enter your latitude here",
"location_picker_longitude_error": "Enter a valid longitude",
"location_picker_longitude_hint": "Enter your longitude here",
"lock": "Lock",
"locked_folder": "Locked Folder",
"log_out": "Log out",
"log_out_all_devices": "Log Out All Devices",

BIN
mobile/openapi/README.md generated

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -2377,7 +2377,7 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PinCodeChangeDto"
"$ref": "#/components/schemas/PinCodeResetDto"
}
}
},
@ -2470,15 +2470,40 @@
]
}
},
"/auth/pin-code/verify": {
"/auth/session/lock": {
"post": {
"operationId": "verifyPinCode",
"operationId": "lockAuthSession",
"parameters": [],
"responses": {
"200": {
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Authentication"
]
}
},
"/auth/session/unlock": {
"post": {
"operationId": "unlockAuthSession",
"parameters": [],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PinCodeSetupDto"
"$ref": "#/components/schemas/SessionUnlockDto"
}
}
},
@ -5695,6 +5720,41 @@
]
}
},
"/sessions/{id}/lock": {
"post": {
"operationId": "lockSession",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"type": "string"
}
}
],
"responses": {
"204": {
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Sessions"
]
}
},
"/shared-links": {
"get": {
"operationId": "getAllSharedLinks",
@ -9327,6 +9387,9 @@
},
"AuthStatusResponseDto": {
"properties": {
"expiresAt": {
"type": "string"
},
"isElevated": {
"type": "boolean"
},
@ -9335,6 +9398,9 @@
},
"pinCode": {
"type": "boolean"
},
"pinExpiresAt": {
"type": "string"
}
},
"required": [
@ -11096,6 +11162,7 @@
"session.read",
"session.update",
"session.delete",
"session.lock",
"sharedLink.create",
"sharedLink.read",
"sharedLink.update",
@ -11297,6 +11364,18 @@
],
"type": "object"
},
"PinCodeResetDto": {
"properties": {
"password": {
"type": "string"
},
"pinCode": {
"example": "123456",
"type": "string"
}
},
"type": "object"
},
"PinCodeSetupDto": {
"properties": {
"pinCode": {
@ -12109,6 +12188,9 @@
"deviceType": {
"type": "string"
},
"expiresAt": {
"type": "string"
},
"id": {
"type": "string"
},
@ -12144,6 +12226,9 @@
"deviceType": {
"type": "string"
},
"expiresAt": {
"type": "string"
},
"id": {
"type": "string"
},
@ -12161,6 +12246,18 @@
],
"type": "object"
},
"SessionUnlockDto": {
"properties": {
"password": {
"type": "string"
},
"pinCode": {
"example": "123456",
"type": "string"
}
},
"type": "object"
},
"SharedLinkCreateDto": {
"properties": {
"albumId": {

View File

@ -512,18 +512,28 @@ export type LogoutResponseDto = {
redirectUri: string;
successful: boolean;
};
export type PinCodeChangeDto = {
newPinCode: string;
export type PinCodeResetDto = {
password?: string;
pinCode?: string;
};
export type PinCodeSetupDto = {
pinCode: string;
};
export type PinCodeChangeDto = {
newPinCode: string;
password?: string;
pinCode?: string;
};
export type SessionUnlockDto = {
password?: string;
pinCode?: string;
};
export type AuthStatusResponseDto = {
expiresAt?: string;
isElevated: boolean;
password: boolean;
pinCode: boolean;
pinExpiresAt?: string;
};
export type ValidateAccessTokenResponseDto = {
authStatus: boolean;
@ -1075,6 +1085,7 @@ export type SessionResponseDto = {
current: boolean;
deviceOS: string;
deviceType: string;
expiresAt?: string;
id: string;
updatedAt: string;
};
@ -1089,6 +1100,7 @@ export type SessionCreateResponseDto = {
current: boolean;
deviceOS: string;
deviceType: string;
expiresAt?: string;
id: string;
token: string;
updatedAt: string;
@ -2066,13 +2078,13 @@ export function logout(opts?: Oazapfts.RequestOpts) {
method: "POST"
}));
}
export function resetPinCode({ pinCodeChangeDto }: {
pinCodeChangeDto: PinCodeChangeDto;
export function resetPinCode({ pinCodeResetDto }: {
pinCodeResetDto: PinCodeResetDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchText("/auth/pin-code", oazapfts.json({
...opts,
method: "DELETE",
body: pinCodeChangeDto
body: pinCodeResetDto
})));
}
export function setupPinCode({ pinCodeSetupDto }: {
@ -2093,13 +2105,19 @@ export function changePinCode({ pinCodeChangeDto }: {
body: pinCodeChangeDto
})));
}
export function verifyPinCode({ pinCodeSetupDto }: {
pinCodeSetupDto: PinCodeSetupDto;
export function lockAuthSession(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchText("/auth/session/lock", {
...opts,
method: "POST"
}));
}
export function unlockAuthSession({ sessionUnlockDto }: {
sessionUnlockDto: SessionUnlockDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchText("/auth/pin-code/verify", oazapfts.json({
return oazapfts.ok(oazapfts.fetchText("/auth/session/unlock", oazapfts.json({
...opts,
method: "POST",
body: pinCodeSetupDto
body: sessionUnlockDto
})));
}
export function getAuthStatus(opts?: Oazapfts.RequestOpts) {
@ -2952,6 +2970,14 @@ export function deleteSession({ id }: {
method: "DELETE"
}));
}
export function lockSession({ id }: {
id: string;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchText(`/sessions/${encodeURIComponent(id)}/lock`, {
...opts,
method: "POST"
}));
}
export function getAllSharedLinks({ albumId }: {
albumId?: string;
}, opts?: Oazapfts.RequestOpts) {
@ -3709,6 +3735,7 @@ export enum Permission {
SessionRead = "session.read",
SessionUpdate = "session.update",
SessionDelete = "session.delete",
SessionLock = "session.lock",
SharedLinkCreate = "sharedLink.create",
SharedLinkRead = "sharedLink.read",
SharedLinkUpdate = "sharedLink.update",

View File

@ -9,7 +9,9 @@ import {
LoginResponseDto,
LogoutResponseDto,
PinCodeChangeDto,
PinCodeResetDto,
PinCodeSetupDto,
SessionUnlockDto,
SignUpDto,
ValidateAccessTokenResponseDto,
} from 'src/dtos/auth.dto';
@ -98,14 +100,21 @@ export class AuthController {
@Delete('pin-code')
@Authenticated()
async resetPinCode(@Auth() auth: AuthDto, @Body() dto: PinCodeChangeDto): Promise<void> {
async resetPinCode(@Auth() auth: AuthDto, @Body() dto: PinCodeResetDto): Promise<void> {
return this.service.resetPinCode(auth, dto);
}
@Post('pin-code/verify')
@Post('session/unlock')
@HttpCode(HttpStatus.OK)
@Authenticated()
async verifyPinCode(@Auth() auth: AuthDto, @Body() dto: PinCodeSetupDto): Promise<void> {
return this.service.verifyPinCode(auth, dto);
async unlockAuthSession(@Auth() auth: AuthDto, @Body() dto: SessionUnlockDto): Promise<void> {
return this.service.unlockSession(auth, dto);
}
@Post('session/lock')
@HttpCode(HttpStatus.OK)
@Authenticated()
async lockAuthSession(@Auth() auth: AuthDto): Promise<void> {
return this.service.lockSession(auth);
}
}

View File

@ -37,4 +37,11 @@ export class SessionController {
deleteSession(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.delete(auth, id);
}
@Post(':id/lock')
@Authenticated({ permission: Permission.SESSION_LOCK })
@HttpCode(HttpStatus.NO_CONTENT)
lockSession(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.lock(auth, id);
}
}

View File

@ -232,6 +232,7 @@ export type Session = {
id: string;
createdAt: Date;
updatedAt: Date;
expiresAt: Date | null;
deviceOS: string;
deviceType: string;
pinExpiresAt: Date | null;

2
server/src/db.d.ts vendored
View File

@ -344,7 +344,7 @@ export interface Sessions {
deviceType: Generated<string>;
id: Generated<string>;
parentId: string | null;
expiredAt: Date | null;
expiresAt: Date | null;
token: string;
updatedAt: Generated<Timestamp>;
updateId: Generated<string>;

View File

@ -93,6 +93,8 @@ export class PinCodeResetDto {
password?: string;
}
export class SessionUnlockDto extends PinCodeResetDto {}
export class PinCodeChangeDto extends PinCodeResetDto {
@PinCode()
newPinCode!: string;
@ -139,4 +141,6 @@ export class AuthStatusResponseDto {
pinCode!: boolean;
password!: boolean;
isElevated!: boolean;
expiresAt?: string;
pinExpiresAt?: string;
}

View File

@ -24,6 +24,7 @@ export class SessionResponseDto {
id!: string;
createdAt!: string;
updatedAt!: string;
expiresAt?: string;
current!: boolean;
deviceType!: string;
deviceOS!: string;
@ -37,6 +38,7 @@ export const mapSession = (entity: Session, currentId?: string): SessionResponse
id: entity.id,
createdAt: entity.createdAt.toISOString(),
updatedAt: entity.updatedAt.toISOString(),
expiresAt: entity.expiresAt?.toISOString(),
current: currentId === entity.id,
deviceOS: entity.deviceOS,
deviceType: entity.deviceType,

View File

@ -148,6 +148,7 @@ export enum Permission {
SESSION_READ = 'session.read',
SESSION_UPDATE = 'session.update',
SESSION_DELETE = 'session.delete',
SESSION_LOCK = 'session.lock',
SHARED_LINK_CREATE = 'sharedLink.create',
SHARED_LINK_READ = 'sharedLink.read',

View File

@ -199,6 +199,15 @@ where
"partners"."sharedById" in ($1)
and "partners"."sharedWithId" = $2
-- AccessRepository.session.checkOwnerAccess
select
"sessions"."id"
from
"sessions"
where
"sessions"."id" in ($1)
and "sessions"."userId" = $2
-- AccessRepository.stack.checkOwnerAccess
select
"stacks"."id"

View File

@ -1,12 +1,14 @@
-- NOTE: This file is auto generated by ./sql-generator
-- SessionRepository.search
-- SessionRepository.get
select
*
"id",
"expiresAt",
"pinExpiresAt"
from
"sessions"
where
"sessions"."updatedAt" <= $1
"id" = $1
-- SessionRepository.getByToken
select
@ -37,8 +39,8 @@ from
where
"sessions"."token" = $1
and (
"sessions"."expiredAt" is null
or "sessions"."expiredAt" > $2
"sessions"."expiresAt" is null
or "sessions"."expiresAt" > $2
)
-- SessionRepository.getByUserId
@ -50,6 +52,10 @@ from
and "users"."deletedAt" is null
where
"sessions"."userId" = $1
and (
"sessions"."expiresAt" is null
or "sessions"."expiresAt" > $2
)
order by
"sessions"."updatedAt" desc,
"sessions"."createdAt" desc
@ -58,3 +64,10 @@ order by
delete from "sessions"
where
"id" = $1::uuid
-- SessionRepository.lockAll
update "sessions"
set
"pinExpiresAt" = $1
where
"userId" = $2

View File

@ -306,6 +306,25 @@ class NotificationAccess {
}
}
class SessionAccess {
constructor(private db: Kysely<DB>) {}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] })
@ChunkedSet({ paramIndex: 1 })
async checkOwnerAccess(userId: string, sessionIds: Set<string>) {
if (sessionIds.size === 0) {
return new Set<string>();
}
return this.db
.selectFrom('sessions')
.select('sessions.id')
.where('sessions.id', 'in', [...sessionIds])
.where('sessions.userId', '=', userId)
.execute()
.then((sessions) => new Set(sessions.map((session) => session.id)));
}
}
class StackAccess {
constructor(private db: Kysely<DB>) {}
@ -456,6 +475,7 @@ export class AccessRepository {
notification: NotificationAccess;
person: PersonAccess;
partner: PartnerAccess;
session: SessionAccess;
stack: StackAccess;
tag: TagAccess;
timeline: TimelineAccess;
@ -469,6 +489,7 @@ export class AccessRepository {
this.notification = new NotificationAccess(db);
this.person = new PersonAccess(db);
this.partner = new PartnerAccess(db);
this.session = new SessionAccess(db);
this.stack = new StackAccess(db);
this.tag = new TagAccess(db);
this.timeline = new TimelineAccess(db);

View File

@ -20,20 +20,20 @@ export class SessionRepository {
.where((eb) =>
eb.or([
eb('updatedAt', '<=', DateTime.now().minus({ days: 90 }).toJSDate()),
eb.and([eb('expiredAt', 'is not', null), eb('expiredAt', '<=', DateTime.now().toJSDate())]),
eb.and([eb('expiresAt', 'is not', null), eb('expiresAt', '<=', DateTime.now().toJSDate())]),
]),
)
.returning(['id', 'deviceOS', 'deviceType'])
.execute();
}
@GenerateSql({ params: [{ updatedBefore: DummyValue.DATE }] })
search(options: SessionSearchOptions) {
@GenerateSql({ params: [DummyValue.UUID] })
get(id: string) {
return this.db
.selectFrom('sessions')
.selectAll()
.where('sessions.updatedAt', '<=', options.updatedBefore)
.execute();
.select(['id', 'expiresAt', 'pinExpiresAt'])
.where('id', '=', id)
.executeTakeFirst();
}
@GenerateSql({ params: [DummyValue.STRING] })
@ -52,7 +52,7 @@ export class SessionRepository {
])
.where('sessions.token', '=', token)
.where((eb) =>
eb.or([eb('sessions.expiredAt', 'is', null), eb('sessions.expiredAt', '>', DateTime.now().toJSDate())]),
eb.or([eb('sessions.expiresAt', 'is', null), eb('sessions.expiresAt', '>', DateTime.now().toJSDate())]),
)
.executeTakeFirst();
}
@ -64,6 +64,9 @@ export class SessionRepository {
.innerJoin('users', (join) => join.onRef('users.id', '=', 'sessions.userId').on('users.deletedAt', 'is', null))
.selectAll('sessions')
.where('sessions.userId', '=', userId)
.where((eb) =>
eb.or([eb('sessions.expiresAt', 'is', null), eb('sessions.expiresAt', '>', DateTime.now().toJSDate())]),
)
.orderBy('sessions.updatedAt', 'desc')
.orderBy('sessions.createdAt', 'desc')
.execute();
@ -86,4 +89,9 @@ export class SessionRepository {
async delete(id: string) {
await this.db.deleteFrom('sessions').where('id', '=', asUuid(id)).execute();
}
@GenerateSql({ params: [DummyValue.UUID] })
async lockAll(userId: string) {
await this.db.updateTable('sessions').set({ pinExpiresAt: null }).where('userId', '=', userId).execute();
}
}

View File

@ -0,0 +1,9 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "sessions" RENAME "expiredAt" TO "expiresAt";`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "sessions" RENAME "expiresAt" TO "expiredAt";`.execute(db);
}

View File

@ -26,7 +26,7 @@ export class SessionTable {
updatedAt!: Date;
@Column({ type: 'timestamp with time zone', nullable: true })
expiredAt!: Date | null;
expiresAt!: Date | null;
@ForeignKeyColumn(() => UserTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE' })
userId!: string;

View File

@ -924,13 +924,13 @@ describe(AuthService.name, () => {
const user = factory.userAdmin();
mocks.user.getForPinCode.mockResolvedValue({ pinCode: '123456 (hashed)', password: '' });
mocks.crypto.compareBcrypt.mockImplementation((a, b) => `${a} (hashed)` === b);
mocks.session.getByUserId.mockResolvedValue([currentSession]);
mocks.session.lockAll.mockResolvedValue(void 0);
mocks.session.update.mockResolvedValue(currentSession);
await sut.resetPinCode(factory.auth({ user }), { pinCode: '123456' });
expect(mocks.user.update).toHaveBeenCalledWith(user.id, { pinCode: null });
expect(mocks.session.update).toHaveBeenCalledWith(currentSession.id, { pinExpiresAt: null });
expect(mocks.session.lockAll).toHaveBeenCalledWith(user.id);
});
it('should throw if the PIN code does not match', async () => {

View File

@ -18,6 +18,7 @@ import {
PinCodeChangeDto,
PinCodeResetDto,
PinCodeSetupDto,
SessionUnlockDto,
SignUpDto,
mapLoginResponse,
} from 'src/dtos/auth.dto';
@ -123,24 +124,21 @@ export class AuthService extends BaseService {
async resetPinCode(auth: AuthDto, dto: PinCodeResetDto) {
const user = await this.userRepository.getForPinCode(auth.user.id);
this.resetPinChecks(user, dto);
this.validatePinCode(user, dto);
await this.userRepository.update(auth.user.id, { pinCode: null });
const sessions = await this.sessionRepository.getByUserId(auth.user.id);
for (const session of sessions) {
await this.sessionRepository.update(session.id, { pinExpiresAt: null });
}
await this.sessionRepository.lockAll(auth.user.id);
}
async changePinCode(auth: AuthDto, dto: PinCodeChangeDto) {
const user = await this.userRepository.getForPinCode(auth.user.id);
this.resetPinChecks(user, dto);
this.validatePinCode(user, dto);
const hashed = await this.cryptoRepository.hashBcrypt(dto.newPinCode, SALT_ROUNDS);
await this.userRepository.update(auth.user.id, { pinCode: hashed });
}
private resetPinChecks(
private validatePinCode(
user: { pinCode: string | null; password: string | null },
dto: { pinCode?: string; password?: string },
) {
@ -474,23 +472,27 @@ export class AuthService extends BaseService {
throw new UnauthorizedException('Invalid user token');
}
async verifyPinCode(auth: AuthDto, dto: PinCodeSetupDto): Promise<void> {
const user = await this.userRepository.getForPinCode(auth.user.id);
if (!user) {
throw new UnauthorizedException();
}
this.resetPinChecks(user, { pinCode: dto.pinCode });
async unlockSession(auth: AuthDto, dto: SessionUnlockDto): Promise<void> {
if (!auth.session) {
throw new BadRequestException('Session is missing');
throw new BadRequestException('This endpoint can only be used with a session token');
}
const user = await this.userRepository.getForPinCode(auth.user.id);
this.validatePinCode(user, { pinCode: dto.pinCode });
await this.sessionRepository.update(auth.session.id, {
pinExpiresAt: new Date(DateTime.now().plus({ minutes: 15 }).toJSDate()),
pinExpiresAt: DateTime.now().plus({ minutes: 15 }).toJSDate(),
});
}
async lockSession(auth: AuthDto): Promise<void> {
if (!auth.session) {
throw new BadRequestException('This endpoint can only be used with a session token');
}
await this.sessionRepository.update(auth.session.id, { pinExpiresAt: null });
}
private async createLoginResponse(user: UserAdmin, loginDetails: LoginDetails) {
const token = this.cryptoRepository.randomBytesAsText(32);
const tokenHashed = this.cryptoRepository.hashSha256(token);
@ -526,10 +528,14 @@ export class AuthService extends BaseService {
throw new UnauthorizedException();
}
const session = auth.session ? await this.sessionRepository.get(auth.session.id) : undefined;
return {
pinCode: !!user.pinCode,
password: !!user.password,
isElevated: !!auth.session?.hasElevatedPermission,
expiresAt: session?.expiresAt?.toISOString(),
pinExpiresAt: session?.pinExpiresAt?.toISOString(),
};
}
}

View File

@ -30,7 +30,7 @@ export class SessionService extends BaseService {
const session = await this.sessionRepository.create({
parentId: auth.session.id,
userId: auth.user.id,
expiredAt: dto.duration ? DateTime.now().plus({ seconds: dto.duration }).toJSDate() : null,
expiresAt: dto.duration ? DateTime.now().plus({ seconds: dto.duration }).toJSDate() : null,
deviceType: dto.deviceType,
deviceOS: dto.deviceOS,
token: tokenHashed,
@ -49,6 +49,11 @@ export class SessionService extends BaseService {
await this.sessionRepository.delete(id);
}
async lock(auth: AuthDto, id: string): Promise<void> {
await this.requireAccess({ auth, permission: Permission.SESSION_LOCK, ids: [id] });
await this.sessionRepository.update(id, { pinExpiresAt: null });
}
async deleteAll(auth: AuthDto): Promise<void> {
const sessions = await this.sessionRepository.getByUserId(auth.user.id);
for (const session of sessions) {

View File

@ -280,6 +280,13 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe
return await access.partner.checkUpdateAccess(auth.user.id, ids);
}
case Permission.SESSION_READ:
case Permission.SESSION_UPDATE:
case Permission.SESSION_DELETE:
case Permission.SESSION_LOCK: {
return access.session.checkOwnerAccess(auth.user.id, ids);
}
case Permission.STACK_READ: {
return access.stack.checkOwnerAccess(auth.user.id, ids);
}

View File

@ -50,6 +50,10 @@ export const newAccessRepositoryMock = (): IAccessRepositoryMock => {
checkUpdateAccess: vitest.fn().mockResolvedValue(new Set()),
},
session: {
checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()),
},
stack: {
checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()),
},

View File

@ -127,7 +127,7 @@ const sessionFactory = (session: Partial<Session> = {}) => ({
deviceType: 'mobile',
token: 'abc123',
parentId: null,
expiredAt: null,
expiresAt: null,
userId: newUuid(),
pinExpiresAt: newDate(),
...session,

View File

@ -1,4 +1,5 @@
<script lang="ts">
import { goto } from '$app/navigation';
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import ChangeDate from '$lib/components/photos-page/actions/change-date-action.svelte';
import ChangeLocation from '$lib/components/photos-page/actions/change-location-action.svelte';
@ -10,11 +11,12 @@
import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
import { AssetAction } from '$lib/constants';
import { AppRoute, AssetAction } from '$lib/constants';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { AssetStore } from '$lib/stores/assets-store.svelte';
import { AssetVisibility } from '@immich/sdk';
import { mdiDotsVertical } from '@mdi/js';
import { AssetVisibility, lockAuthSession } from '@immich/sdk';
import { Button } from '@immich/ui';
import { mdiDotsVertical, mdiLockOutline } from '@mdi/js';
import { onDestroy } from 'svelte';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
@ -42,6 +44,11 @@
assetInteraction.clearMultiselect();
assetStore.removeAssets(assetIds);
};
const handleLock = async () => {
await lockAuthSession();
await goto(AppRoute.PHOTOS);
};
</script>
<!-- Multi-selection mode app bar -->
@ -62,6 +69,12 @@
{/if}
<UserPageLayout hideNavbar={assetInteraction.selectionActive} title={data.meta.title} scrollbar={false}>
{#snippet buttons()}
<Button size="small" variant="filled" color="warning" leadingIcon={mdiLockOutline} onclick={handleLock}>
{$t('lock')}
</Button>
{/snippet}
<AssetGrid
enableRouting={true}
{assetStore}

View File

@ -8,14 +8,12 @@ import type { PageLoad } from './$types';
export const load = (async ({ params, url }) => {
await authenticate(url);
const { isElevated, pinCode } = await getAuthStatus();
if (!isElevated || !pinCode) {
const continuePath = encodeURIComponent(url.pathname);
const redirectPath = `${AppRoute.AUTH_PIN_PROMPT}?continue=${continuePath}`;
redirect(302, redirectPath);
redirect(302, `${AppRoute.AUTH_PIN_PROMPT}?continue=${encodeURIComponent(url.pathname + url.search)}`);
}
const asset = await getAssetInfoFromParam(params);
const $t = await getFormatter();

View File

@ -3,9 +3,8 @@
import AuthPageLayout from '$lib/components/layouts/AuthPageLayout.svelte';
import PinCodeCreateForm from '$lib/components/user-settings-page/PinCodeCreateForm.svelte';
import PincodeInput from '$lib/components/user-settings-page/PinCodeInput.svelte';
import { AppRoute } from '$lib/constants';
import { handleError } from '$lib/utils/handle-error';
import { verifyPinCode } from '@immich/sdk';
import { unlockAuthSession } from '@immich/sdk';
import { Icon } from '@immich/ui';
import { mdiLockOpenVariantOutline, mdiLockOutline, mdiLockSmart } from '@mdi/js';
import { t } from 'svelte-i18n';
@ -23,17 +22,15 @@
let hasPinCode = $derived(data.hasPinCode);
let pinCode = $state('');
const onPinFilled = async (code: string, withDelay = false) => {
const handleUnlockSession = async (code: string) => {
try {
await verifyPinCode({ pinCodeSetupDto: { pinCode: code } });
await unlockAuthSession({ sessionUnlockDto: { pinCode: code } });
isVerified = true;
if (withDelay) {
await new Promise((resolve) => setTimeout(resolve, 1000));
}
await new Promise((resolve) => setTimeout(resolve, 1000));
void goto(data.continuePath ?? AppRoute.LOCKED);
await goto(data.continueUrl);
} catch (error) {
handleError(error, $t('wrong_pin_code'));
isBadPinCode = true;
@ -64,7 +61,7 @@
bind:value={pinCode}
tabindexStart={1}
pinLength={6}
onFilled={(pinCode) => onPinFilled(pinCode, true)}
onFilled={handleUnlockSession}
/>
</div>
</div>

View File

@ -1,3 +1,4 @@
import { AppRoute } from '$lib/constants';
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import { getAuthStatus } from '@immich/sdk';
@ -8,8 +9,6 @@ export const load = (async ({ url }) => {
const { pinCode } = await getAuthStatus();
const continuePath = url.searchParams.get('continue');
const $t = await getFormatter();
return {
@ -17,6 +16,6 @@ export const load = (async ({ url }) => {
title: $t('pin_verification'),
},
hasPinCode: !!pinCode,
continuePath,
continueUrl: url.searchParams.get('continue') || AppRoute.LOCKED,
};
}) satisfies PageLoad;