1
0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2025-01-16 16:56:46 +01:00

feat(web,server): manage authorized devices (#2329)

* feat: manage authorized devices

* chore: open api

* get header from mobile app

* write header from mobile app

* styling

* fix unit test

* feat: use relative time

* feat: update access time

* fix: tests

* chore: confirm wording

* chore: bump test coverage thresholds

* feat: add some icons

* chore: icon tweaks

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
Jason Rasmussen 2023-04-25 22:19:23 -04:00 committed by GitHub
parent aa91b946fa
commit b8313abfa8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
41 changed files with 785 additions and 91 deletions

View file

@ -1,5 +1,6 @@
import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
@ -49,6 +50,22 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
}
// Make sign-in request
DeviceInfoPlugin deviceInfoPlugin = DeviceInfoPlugin();
if (Platform.isIOS) {
var iosInfo = await deviceInfoPlugin.iosInfo;
_apiService.authenticationApi.apiClient
.addDefaultHeader('deviceModel', iosInfo.utsname.machine ?? '');
_apiService.authenticationApi.apiClient
.addDefaultHeader('deviceType', 'iOS');
} else {
var androidInfo = await deviceInfoPlugin.androidInfo;
_apiService.authenticationApi.apiClient
.addDefaultHeader('deviceModel', androidInfo.model);
_apiService.authenticationApi.apiClient
.addDefaultHeader('deviceType', 'Android');
}
try {
var loginResponse = await _apiService.authenticationApi.login(
LoginCredentialDto(

View file

@ -23,6 +23,7 @@ doc/AssetCountByUserIdResponseDto.md
doc/AssetFileUploadResponseDto.md
doc/AssetResponseDto.md
doc/AssetTypeEnum.md
doc/AuthDeviceResponseDto.md
doc/AuthenticationApi.md
doc/ChangePasswordDto.md
doc/CheckDuplicateAssetDto.md
@ -145,6 +146,7 @@ lib/model/asset_count_by_user_id_response_dto.dart
lib/model/asset_file_upload_response_dto.dart
lib/model/asset_response_dto.dart
lib/model/asset_type_enum.dart
lib/model/auth_device_response_dto.dart
lib/model/change_password_dto.dart
lib/model/check_duplicate_asset_dto.dart
lib/model/check_duplicate_asset_response_dto.dart
@ -238,6 +240,7 @@ test/asset_count_by_user_id_response_dto_test.dart
test/asset_file_upload_response_dto_test.dart
test/asset_response_dto_test.dart
test/asset_type_enum_test.dart
test/auth_device_response_dto_test.dart
test/authentication_api_test.dart
test/change_password_dto_test.dart
test/check_duplicate_asset_dto_test.dart

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.

Binary file not shown.

View file

@ -1,5 +1,6 @@
import {
AdminSignupResponseDto,
AuthDeviceResponseDto,
AuthService,
AuthType,
AuthUserDto,
@ -7,18 +8,20 @@ import {
IMMICH_ACCESS_COOKIE,
IMMICH_AUTH_TYPE_COOKIE,
LoginCredentialDto,
LoginDetails,
LoginResponseDto,
LogoutResponseDto,
SignUpDto,
UserResponseDto,
ValidateAccessTokenResponseDto,
} from '@app/domain';
import { Body, Controller, Ip, Post, Req, Res } from '@nestjs/common';
import { Body, Controller, Delete, Get, Param, Post, Req, Res } from '@nestjs/common';
import { ApiBadRequestResponse, ApiTags } from '@nestjs/swagger';
import { Request, Response } from 'express';
import { GetAuthUser } from '../decorators/auth-user.decorator';
import { GetAuthUser, GetLoginDetails } from '../decorators/auth-user.decorator';
import { Authenticated } from '../decorators/authenticated.decorator';
import { UseValidation } from '../decorators/use-validation.decorator';
import { UUIDParamDto } from './dto/uuid-param.dto';
@ApiTags('Authentication')
@Controller('auth')
@ -29,11 +32,10 @@ export class AuthController {
@Post('login')
async login(
@Body() loginCredential: LoginCredentialDto,
@Ip() clientIp: string,
@Req() req: Request,
@Res({ passthrough: true }) res: Response,
@GetLoginDetails() loginDetails: LoginDetails,
): Promise<LoginResponseDto> {
const { response, cookie } = await this.service.login(loginCredential, clientIp, req.secure);
const { response, cookie } = await this.service.login(loginCredential, loginDetails);
res.header('Set-Cookie', cookie);
return response;
}
@ -44,6 +46,18 @@ export class AuthController {
return this.service.adminSignUp(signUpCredential);
}
@Authenticated()
@Get('devices')
getAuthDevices(@GetAuthUser() authUser: AuthUserDto): Promise<AuthDeviceResponseDto[]> {
return this.service.getDevices(authUser);
}
@Authenticated()
@Delete('devices/:id')
logoutAuthDevice(@GetAuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.logoutDevice(authUser, id);
}
@Authenticated()
@Post('validateToken')
validateAccessToken(): ValidateAccessTokenResponseDto {

View file

@ -1,5 +1,6 @@
import {
AuthUserDto,
LoginDetails,
LoginResponseDto,
OAuthCallbackDto,
OAuthConfigDto,
@ -10,7 +11,7 @@ import {
import { Body, Controller, Get, HttpStatus, Post, Redirect, Req, Res } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Request, Response } from 'express';
import { GetAuthUser } from '../decorators/auth-user.decorator';
import { GetAuthUser, GetLoginDetails } from '../decorators/auth-user.decorator';
import { Authenticated } from '../decorators/authenticated.decorator';
import { UseValidation } from '../decorators/use-validation.decorator';
@ -38,9 +39,9 @@ export class OAuthController {
async callback(
@Res({ passthrough: true }) res: Response,
@Body() dto: OAuthCallbackDto,
@Req() req: Request,
@GetLoginDetails() loginDetails: LoginDetails,
): Promise<LoginResponseDto> {
const { response, cookie } = await this.service.login(dto, req.secure);
const { response, cookie } = await this.service.login(dto, loginDetails);
res.header('Set-Cookie', cookie);
return response;
}

View file

@ -1,7 +1,20 @@
export { AuthUserDto } from '@app/domain';
import { AuthUserDto } from '@app/domain';
import { AuthUserDto, LoginDetails } from '@app/domain';
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { UAParser } from 'ua-parser-js';
export const GetAuthUser = createParamDecorator((data, ctx: ExecutionContext): AuthUserDto => {
return ctx.switchToHttp().getRequest<{ user: AuthUserDto }>().user;
});
export const GetLoginDetails = createParamDecorator((data, ctx: ExecutionContext): LoginDetails => {
const req = ctx.switchToHttp().getRequest();
const userAgent = UAParser(req.headers['user-agent']);
return {
clientIp: req.clientIp,
isSecure: req.secure,
deviceType: userAgent.browser.name || userAgent.device.type || req.headers.devicemodel || '',
deviceOS: userAgent.os.name || req.headers.devicetype || '',
};
});

View file

@ -21,6 +21,10 @@ export function patchOpenAPI(document: OpenAPIObject) {
if (operation.summary === '') {
delete operation.summary;
}
if (operation.description === '') {
delete operation.description;
}
}
}

View file

@ -339,6 +339,70 @@
]
}
},
"/auth/devices": {
"get": {
"operationId": "getAuthDevices",
"parameters": [],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/AuthDeviceResponseDto"
}
}
}
}
}
},
"tags": [
"Authentication"
],
"security": [
{
"bearer": []
},
{
"cookie": []
}
]
}
},
"/auth/devices/{id}": {
"delete": {
"operationId": "logoutAuthDevice",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"type": "string"
}
}
],
"responses": {
"200": {
"description": ""
}
},
"tags": [
"Authentication"
],
"security": [
{
"bearer": []
},
{
"cookie": []
}
]
}
},
"/auth/validateToken": {
"post": {
"operationId": "validateAccessToken",
@ -3986,6 +4050,37 @@
"createdAt"
]
},
"AuthDeviceResponseDto": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"createdAt": {
"type": "string"
},
"updatedAt": {
"type": "string"
},
"current": {
"type": "boolean"
},
"deviceType": {
"type": "string"
},
"deviceOS": {
"type": "string"
}
},
"required": [
"id",
"createdAt",
"updatedAt",
"current",
"deviceType",
"deviceOS"
]
},
"ValidateAccessTokenResponseDto": {
"type": "object",
"properties": {
@ -4018,12 +4113,10 @@
"type": "object",
"properties": {
"successful": {
"type": "boolean",
"readOnly": true
"type": "boolean"
},
"redirectUri": {
"type": "string",
"readOnly": true
"type": "string"
}
},
"required": [

View file

@ -1,10 +1,17 @@
import { SystemConfig, UserEntity } from '@app/infra/entities';
import { ICryptoRepository } from '../crypto/crypto.repository';
import { ISystemConfigRepository } from '../system-config';
import { SystemConfigCore } from '../system-config/system-config.core';
import { AuthType, IMMICH_ACCESS_COOKIE, IMMICH_AUTH_TYPE_COOKIE } from './auth.constant';
import { ICryptoRepository } from '../crypto/crypto.repository';
import { LoginResponseDto, mapLoginResponse } from './response-dto';
import { IUserTokenRepository, UserTokenCore } from '../user-token';
import { AuthType, IMMICH_ACCESS_COOKIE, IMMICH_AUTH_TYPE_COOKIE } from './auth.constant';
import { LoginResponseDto, mapLoginResponse } from './response-dto';
export interface LoginDetails {
isSecure: boolean;
clientIp: string;
deviceType: string;
deviceOS: string;
}
export class AuthCore {
private userTokenCore: UserTokenCore;
@ -23,7 +30,7 @@ export class AuthCore {
return this.config.passwordLogin.enabled;
}
public getCookies(loginResponse: LoginResponseDto, authType: AuthType, isSecure: boolean) {
getCookies(loginResponse: LoginResponseDto, authType: AuthType, { isSecure }: LoginDetails) {
const maxAge = 400 * 24 * 3600; // 400 days
let authTypeCookie = '';
@ -39,10 +46,10 @@ export class AuthCore {
return [accessTokenCookie, authTypeCookie];
}
public async createLoginResponse(user: UserEntity, authType: AuthType, isSecure: boolean) {
const accessToken = await this.userTokenCore.createToken(user);
async createLoginResponse(user: UserEntity, authType: AuthType, loginDetails: LoginDetails) {
const accessToken = await this.userTokenCore.create(user, loginDetails);
const response = mapLoginResponse(user, accessToken);
const cookie = this.getCookies(response, authType, isSecure);
const cookie = this.getCookies(response, authType, loginDetails);
return { response, cookie };
}

View file

@ -32,6 +32,12 @@ import { AuthUserDto, SignUpDto } from './dto';
const email = 'test@immich.com';
const sub = 'my-auth-user-sub';
const loginDetails = {
isSecure: true,
clientIp: '127.0.0.1',
deviceOS: '',
deviceType: '',
};
const fixtures = {
login: {
@ -40,8 +46,6 @@ const fixtures = {
},
};
const CLIENT_IP = '127.0.0.1';
describe('AuthService', () => {
let sut: AuthService;
let cryptoMock: jest.Mocked<ICryptoRepository>;
@ -96,32 +100,39 @@ describe('AuthService', () => {
it('should throw an error if password login is disabled', async () => {
sut = create(systemConfigStub.disabled);
await expect(sut.login(fixtures.login, CLIENT_IP, true)).rejects.toBeInstanceOf(UnauthorizedException);
await expect(sut.login(fixtures.login, loginDetails)).rejects.toBeInstanceOf(UnauthorizedException);
});
it('should check the user exists', async () => {
userMock.getByEmail.mockResolvedValue(null);
await expect(sut.login(fixtures.login, CLIENT_IP, true)).rejects.toBeInstanceOf(BadRequestException);
await expect(sut.login(fixtures.login, loginDetails)).rejects.toBeInstanceOf(BadRequestException);
expect(userMock.getByEmail).toHaveBeenCalledTimes(1);
});
it('should check the user has a password', async () => {
userMock.getByEmail.mockResolvedValue({} as UserEntity);
await expect(sut.login(fixtures.login, CLIENT_IP, true)).rejects.toBeInstanceOf(BadRequestException);
await expect(sut.login(fixtures.login, loginDetails)).rejects.toBeInstanceOf(BadRequestException);
expect(userMock.getByEmail).toHaveBeenCalledTimes(1);
});
it('should successfully log the user in', async () => {
userMock.getByEmail.mockResolvedValue(userEntityStub.user1);
userTokenMock.create.mockResolvedValue(userTokenEntityStub.userToken);
await expect(sut.login(fixtures.login, CLIENT_IP, true)).resolves.toEqual(loginResponseStub.user1password);
await expect(sut.login(fixtures.login, loginDetails)).resolves.toEqual(loginResponseStub.user1password);
expect(userMock.getByEmail).toHaveBeenCalledTimes(1);
});
it('should generate the cookie headers (insecure)', async () => {
userMock.getByEmail.mockResolvedValue(userEntityStub.user1);
userTokenMock.create.mockResolvedValue(userTokenEntityStub.userToken);
await expect(sut.login(fixtures.login, CLIENT_IP, false)).resolves.toEqual(loginResponseStub.user1insecure);
await expect(
sut.login(fixtures.login, {
clientIp: '127.0.0.1',
isSecure: false,
deviceOS: '',
deviceType: '',
}),
).resolves.toEqual(loginResponseStub.user1insecure);
expect(userMock.getByEmail).toHaveBeenCalledTimes(1);
});
});
@ -205,7 +216,7 @@ describe('AuthService', () => {
redirectUri: '/auth/login?autoLaunch=0',
});
expect(userTokenMock.delete).toHaveBeenCalledWith('token123');
expect(userTokenMock.delete).toHaveBeenCalledWith('123', 'token123');
});
});
@ -240,7 +251,7 @@ describe('AuthService', () => {
it('should validate using authorization header', async () => {
userMock.get.mockResolvedValue(userEntityStub.user1);
userTokenMock.get.mockResolvedValue(userTokenEntityStub.userToken);
userTokenMock.getByToken.mockResolvedValue(userTokenEntityStub.userToken);
const client = { request: { headers: { authorization: 'Bearer auth_token' } } };
await expect(sut.validate((client as Socket).request.headers, {})).resolves.toEqual(userEntityStub.user1);
});
@ -276,16 +287,32 @@ describe('AuthService', () => {
describe('validate - user token', () => {
it('should throw if no token is found', async () => {
userTokenMock.get.mockResolvedValue(null);
userTokenMock.getByToken.mockResolvedValue(null);
const headers: IncomingHttpHeaders = { 'x-immich-user-token': 'auth_token' };
await expect(sut.validate(headers, {})).rejects.toBeInstanceOf(UnauthorizedException);
});
it('should return an auth dto', async () => {
userTokenMock.get.mockResolvedValue(userTokenEntityStub.userToken);
userTokenMock.getByToken.mockResolvedValue(userTokenEntityStub.userToken);
const headers: IncomingHttpHeaders = { cookie: 'immich_access_token=auth_token' };
await expect(sut.validate(headers, {})).resolves.toEqual(userEntityStub.user1);
});
it('should update when access time exceeds an hour', async () => {
userTokenMock.getByToken.mockResolvedValue(userTokenEntityStub.inactiveToken);
userTokenMock.save.mockResolvedValue(userTokenEntityStub.userToken);
const headers: IncomingHttpHeaders = { cookie: 'immich_access_token=auth_token' };
await expect(sut.validate(headers, {})).resolves.toEqual(userEntityStub.user1);
expect(userTokenMock.save.mock.calls[0][0]).toMatchObject({
id: 'not_active',
token: 'auth_token',
userId: 'immich_id',
createdAt: new Date('2021-01-01'),
updatedAt: expect.any(Date),
deviceOS: 'Android',
deviceType: 'Mobile',
});
});
});
describe('validate - api key', () => {
@ -303,4 +330,38 @@ describe('AuthService', () => {
expect(keyMock.getKey).toHaveBeenCalledWith('auth_token (hashed)');
});
});
describe('getDevices', () => {
it('should get the devices', async () => {
userTokenMock.getAll.mockResolvedValue([userTokenEntityStub.userToken, userTokenEntityStub.inactiveToken]);
await expect(sut.getDevices(authStub.user1)).resolves.toEqual([
{
createdAt: '2021-01-01T00:00:00.000Z',
current: true,
deviceOS: '',
deviceType: '',
id: 'token-id',
updatedAt: expect.any(String),
},
{
createdAt: '2021-01-01T00:00:00.000Z',
current: false,
deviceOS: 'Android',
deviceType: 'Mobile',
id: 'not_active',
updatedAt: expect.any(String),
},
]);
expect(userTokenMock.getAll).toHaveBeenCalledWith(authStub.user1.id);
});
});
describe('logoutDevice', () => {
it('should logout the device', async () => {
await sut.logoutDevice(authStub.user1, 'token-1');
expect(userTokenMock.delete).toHaveBeenCalledWith(authStub.user1.id, 'token-1');
});
});
});

View file

@ -12,7 +12,7 @@ import { OAuthCore } from '../oauth/oauth.core';
import { INITIAL_SYSTEM_CONFIG, ISystemConfigRepository } from '../system-config';
import { IUserRepository, UserCore } from '../user';
import { AuthType, IMMICH_ACCESS_COOKIE } from './auth.constant';
import { AuthCore } from './auth.core';
import { AuthCore, LoginDetails } from './auth.core';
import { ICryptoRepository } from '../crypto/crypto.repository';
import { AuthUserDto, ChangePasswordDto, LoginCredentialDto, SignUpDto } from './dto';
import { AdminSignupResponseDto, LoginResponseDto, LogoutResponseDto, mapAdminSignupResponse } from './response-dto';
@ -21,6 +21,7 @@ import cookieParser from 'cookie';
import { ISharedLinkRepository, ShareCore } from '../share';
import { APIKeyCore } from '../api-key/api-key.core';
import { IKeyRepository } from '../api-key';
import { AuthDeviceResponseDto, mapUserToken } from './response-dto';
@Injectable()
export class AuthService {
@ -53,8 +54,7 @@ export class AuthService {
public async login(
loginCredential: LoginCredentialDto,
clientIp: string,
isSecure: boolean,
loginDetails: LoginDetails,
): Promise<{ response: LoginResponseDto; cookie: string[] }> {
if (!this.authCore.isPasswordLoginEnabled()) {
throw new UnauthorizedException('Password login has been disabled');
@ -69,16 +69,18 @@ export class AuthService {
}
if (!user) {
this.logger.warn(`Failed login attempt for user ${loginCredential.email} from ip address ${clientIp}`);
this.logger.warn(
`Failed login attempt for user ${loginCredential.email} from ip address ${loginDetails.clientIp}`,
);
throw new BadRequestException('Incorrect email or password');
}
return this.authCore.createLoginResponse(user, AuthType.PASSWORD, isSecure);
return this.authCore.createLoginResponse(user, AuthType.PASSWORD, loginDetails);
}
public async logout(authUser: AuthUserDto, authType: AuthType): Promise<LogoutResponseDto> {
if (authUser.accessTokenId) {
await this.userTokenCore.deleteToken(authUser.accessTokenId);
await this.userTokenCore.delete(authUser.id, authUser.accessTokenId);
}
if (authType === AuthType.OAUTH) {
@ -152,6 +154,15 @@ export class AuthService {
throw new UnauthorizedException('Authentication required');
}
async getDevices(authUser: AuthUserDto): Promise<AuthDeviceResponseDto[]> {
const userTokens = await this.userTokenCore.getAll(authUser.id);
return userTokens.map((userToken) => mapUserToken(userToken, authUser.accessTokenId));
}
async logoutDevice(authUser: AuthUserDto, deviceId: string): Promise<void> {
await this.userTokenCore.delete(authUser.id, deviceId);
}
private getBearerToken(headers: IncomingHttpHeaders): string | null {
const [type, token] = (headers.authorization || '').split(' ');
if (type.toLowerCase() === 'bearer') {

View file

@ -1,4 +1,5 @@
export * from './auth.constant';
export * from './auth.core';
export * from './auth.service';
export * from './dto';
export * from './response-dto';

View file

@ -0,0 +1,19 @@
import { UserTokenEntity } from '@app/infra/entities';
export class AuthDeviceResponseDto {
id!: string;
createdAt!: string;
updatedAt!: string;
current!: boolean;
deviceType!: string;
deviceOS!: string;
}
export const mapUserToken = (entity: UserTokenEntity, currentId?: string): AuthDeviceResponseDto => ({
id: entity.id,
createdAt: entity.createdAt.toISOString(),
updatedAt: entity.updatedAt.toISOString(),
current: currentId === entity.id,
deviceOS: entity.deviceOS,
deviceType: entity.deviceType,
});

View file

@ -1,4 +1,5 @@
export * from './admin-signup-response.dto';
export * from './auth-device-response.dto';
export * from './login-response.dto';
export * from './logout-response.dto';
export * from './validate-asset-token-response.dto';

View file

@ -1,13 +1,4 @@
import { ApiResponseProperty } from '@nestjs/swagger';
export class LogoutResponseDto {
constructor(successful: boolean) {
this.successful = successful;
}
@ApiResponseProperty()
successful!: boolean;
@ApiResponseProperty()
redirectUri!: string;
}

View file

@ -1,10 +1,3 @@
import { ApiProperty } from '@nestjs/swagger';
export class ValidateAccessTokenResponseDto {
constructor(authStatus: boolean) {
this.authStatus = authStatus;
}
@ApiProperty({ type: 'boolean' })
authStatus!: boolean;
}

View file

@ -17,9 +17,16 @@ import { ISystemConfigRepository } from '../system-config';
import { IUserRepository } from '../user';
import { IUserTokenRepository } from '../user-token';
import { newUserTokenRepositoryMock } from '../../test/user-token.repository.mock';
import { LoginDetails } from '../auth';
const email = 'user@immich.com';
const sub = 'my-auth-user-sub';
const loginDetails: LoginDetails = {
isSecure: true,
clientIp: '127.0.0.1',
deviceOS: '',
deviceType: '',
};
describe('OAuthService', () => {
let sut: OAuthService;
@ -95,13 +102,13 @@ describe('OAuthService', () => {
describe('login', () => {
it('should throw an error if OAuth is not enabled', async () => {
await expect(sut.login({ url: '' }, true)).rejects.toBeInstanceOf(BadRequestException);
await expect(sut.login({ url: '' }, loginDetails)).rejects.toBeInstanceOf(BadRequestException);
});
it('should not allow auto registering', async () => {
sut = create(systemConfigStub.noAutoRegister);
userMock.getByEmail.mockResolvedValue(null);
await expect(sut.login({ url: 'http://immich/auth/login?code=abc123' }, true)).rejects.toBeInstanceOf(
await expect(sut.login({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).rejects.toBeInstanceOf(
BadRequestException,
);
expect(userMock.getByEmail).toHaveBeenCalledTimes(1);
@ -113,7 +120,7 @@ describe('OAuthService', () => {
userMock.update.mockResolvedValue(userEntityStub.user1);
userTokenMock.create.mockResolvedValue(userTokenEntityStub.userToken);
await expect(sut.login({ url: 'http://immich/auth/login?code=abc123' }, true)).resolves.toEqual(
await expect(sut.login({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
loginResponseStub.user1oauth,
);
@ -129,7 +136,7 @@ describe('OAuthService', () => {
userMock.create.mockResolvedValue(userEntityStub.user1);
userTokenMock.create.mockResolvedValue(userTokenEntityStub.userToken);
await expect(sut.login({ url: 'http://immich/auth/login?code=abc123' }, true)).resolves.toEqual(
await expect(sut.login({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
loginResponseStub.user1oauth,
);
@ -143,7 +150,7 @@ describe('OAuthService', () => {
userMock.getByOAuthId.mockResolvedValue(userEntityStub.user1);
userTokenMock.create.mockResolvedValue(userTokenEntityStub.userToken);
await sut.login({ url: `app.immich:/?code=abc123` }, true);
await sut.login({ url: `app.immich:/?code=abc123` }, loginDetails);
expect(callbackMock).toHaveBeenCalledWith('http://mobile-redirect', { state: 'state' }, { state: 'state' });
});

View file

@ -1,7 +1,7 @@
import { SystemConfig } from '@app/infra/entities';
import { BadRequestException, Inject, Injectable, Logger } from '@nestjs/common';
import { AuthType, AuthUserDto, LoginResponseDto } from '../auth';
import { AuthCore } from '../auth/auth.core';
import { AuthCore, LoginDetails } from '../auth/auth.core';
import { ICryptoRepository } from '../crypto';
import { INITIAL_SYSTEM_CONFIG, ISystemConfigRepository } from '../system-config';
import { IUserRepository, UserCore, UserResponseDto } from '../user';
@ -39,7 +39,10 @@ export class OAuthService {
return this.oauthCore.generateConfig(dto);
}
async login(dto: OAuthCallbackDto, isSecure: boolean): Promise<{ response: LoginResponseDto; cookie: string[] }> {
async login(
dto: OAuthCallbackDto,
loginDetails: LoginDetails,
): Promise<{ response: LoginResponseDto; cookie: string[] }> {
const profile = await this.oauthCore.callback(dto.url);
this.logger.debug(`Logging in with OAuth: ${JSON.stringify(profile)}`);
@ -66,7 +69,7 @@ export class OAuthService {
user = await this.userCore.createUser(this.oauthCore.asUser(profile));
}
return this.authCore.createLoginResponse(user, AuthType.OAUTH, isSecure);
return this.authCore.createLoginResponse(user, AuthType.OAUTH, loginDetails);
}
public async link(user: AuthUserDto, dto: OAuthCallbackDto): Promise<UserResponseDto> {

View file

@ -1,5 +1,7 @@
import { UserEntity } from '@app/infra/entities';
import { UserEntity, UserTokenEntity } from '@app/infra/entities';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { DateTime } from 'luxon';
import { LoginDetails } from '../auth';
import { ICryptoRepository } from '../crypto';
import { IUserTokenRepository } from './user-token.repository';
@ -9,9 +11,16 @@ export class UserTokenCore {
async validate(tokenValue: string) {
const hashedToken = this.crypto.hashSha256(tokenValue);
const token = await this.repository.get(hashedToken);
let token = await this.repository.getByToken(hashedToken);
if (token?.user) {
const now = DateTime.now();
const updatedAt = DateTime.fromJSDate(token.updatedAt);
const diff = now.diff(updatedAt, ['hours']);
if (diff.hours > 1) {
token = await this.repository.save({ ...token, updatedAt: new Date() });
}
return {
...token.user,
isPublicUser: false,
@ -25,18 +34,24 @@ export class UserTokenCore {
throw new UnauthorizedException('Invalid user token');
}
public async createToken(user: UserEntity): Promise<string> {
async create(user: UserEntity, loginDetails: LoginDetails): Promise<string> {
const key = this.crypto.randomBytes(32).toString('base64').replace(/\W/g, '');
const token = this.crypto.hashSha256(key);
await this.repository.create({
token,
user,
deviceOS: loginDetails.deviceOS,
deviceType: loginDetails.deviceType,
});
return key;
}
public async deleteToken(id: string): Promise<void> {
await this.repository.delete(id);
async delete(userId: string, id: string): Promise<void> {
await this.repository.delete(userId, id);
}
getAll(userId: string): Promise<UserTokenEntity[]> {
return this.repository.getAll(userId);
}
}

View file

@ -4,7 +4,9 @@ export const IUserTokenRepository = 'IUserTokenRepository';
export interface IUserTokenRepository {
create(dto: Partial<UserTokenEntity>): Promise<UserTokenEntity>;
delete(userToken: string): Promise<void>;
save(dto: Partial<UserTokenEntity>): Promise<UserTokenEntity>;
delete(userId: string, id: string): Promise<void>;
deleteAll(userId: string): Promise<void>;
get(userToken: string): Promise<UserTokenEntity | null>;
getByToken(token: string): Promise<UserTokenEntity | null>;
getAll(userId: string): Promise<UserTokenEntity[]>;
}

View file

@ -391,9 +391,22 @@ export const userTokenEntityStub = {
userToken: Object.freeze<UserTokenEntity>({
id: 'token-id',
token: 'auth_token',
userId: userEntityStub.user1.id,
user: userEntityStub.user1,
createdAt: '2021-01-01',
updatedAt: '2021-01-01',
createdAt: new Date('2021-01-01'),
updatedAt: new Date(),
deviceType: '',
deviceOS: '',
}),
inactiveToken: Object.freeze<UserTokenEntity>({
id: 'not_active',
token: 'auth_token',
userId: userEntityStub.user1.id,
user: userEntityStub.user1,
createdAt: new Date('2021-01-01'),
updatedAt: new Date('2021-01-01'),
deviceType: 'Mobile',
deviceOS: 'Android',
}),
};

View file

@ -3,8 +3,10 @@ import { IUserTokenRepository } from '../src';
export const newUserTokenRepositoryMock = (): jest.Mocked<IUserTokenRepository> => {
return {
create: jest.fn(),
save: jest.fn(),
delete: jest.fn(),
deleteAll: jest.fn(),
get: jest.fn(),
getByToken: jest.fn(),
getAll: jest.fn(),
};
};

View file

@ -9,12 +9,21 @@ export class UserTokenEntity {
@Column({ select: false })
token!: string;
@Column()
userId!: string;
@ManyToOne(() => UserEntity)
user!: UserEntity;
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: string;
createdAt!: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt!: string;
updatedAt!: Date;
@Column({ default: '' })
deviceType!: string;
@Column({ default: '' })
deviceOS!: string;
}

View file

@ -0,0 +1,21 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class FixNullableRelations1682371561743 implements MigrationInterface {
name = 'FixNullableRelations1682371561743';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "user_token" DROP CONSTRAINT "FK_d37db50eecdf9b8ce4eedd2f918"`);
await queryRunner.query(`ALTER TABLE "user_token" ALTER COLUMN "userId" SET NOT NULL`);
await queryRunner.query(
`ALTER TABLE "user_token" ADD CONSTRAINT "FK_d37db50eecdf9b8ce4eedd2f918" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "user_token" DROP CONSTRAINT "FK_d37db50eecdf9b8ce4eedd2f918"`);
await queryRunner.query(`ALTER TABLE "user_token" ALTER COLUMN "userId" DROP NOT NULL`);
await queryRunner.query(
`ALTER TABLE "user_token" ADD CONSTRAINT "FK_d37db50eecdf9b8ce4eedd2f918" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
}
}

View file

@ -0,0 +1,16 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddDeviceInfoToUserToken1682371791038 implements MigrationInterface {
name = 'AddDeviceInfoToUserToken1682371791038'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "user_token" ADD "deviceType" character varying NOT NULL DEFAULT ''`);
await queryRunner.query(`ALTER TABLE "user_token" ADD "deviceOS" character varying NOT NULL DEFAULT ''`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "user_token" DROP COLUMN "deviceOS"`);
await queryRunner.query(`ALTER TABLE "user_token" DROP COLUMN "deviceType"`);
}
}

View file

@ -6,24 +6,40 @@ import { IUserTokenRepository } from '@app/domain/user-token';
@Injectable()
export class UserTokenRepository implements IUserTokenRepository {
constructor(
@InjectRepository(UserTokenEntity)
private userTokenRepository: Repository<UserTokenEntity>,
) {}
constructor(@InjectRepository(UserTokenEntity) private repository: Repository<UserTokenEntity>) {}
async get(userToken: string): Promise<UserTokenEntity | null> {
return this.userTokenRepository.findOne({ where: { token: userToken }, relations: { user: true } });
getByToken(token: string): Promise<UserTokenEntity | null> {
return this.repository.findOne({ where: { token }, relations: { user: true } });
}
async create(userToken: Partial<UserTokenEntity>): Promise<UserTokenEntity> {
return this.userTokenRepository.save(userToken);
getAll(userId: string): Promise<UserTokenEntity[]> {
return this.repository.find({
where: {
userId,
},
relations: {
user: true,
},
order: {
updatedAt: 'desc',
createdAt: 'desc',
},
});
}
async delete(id: string): Promise<void> {
await this.userTokenRepository.delete(id);
create(userToken: Partial<UserTokenEntity>): Promise<UserTokenEntity> {
return this.repository.save(userToken);
}
save(userToken: Partial<UserTokenEntity>): Promise<UserTokenEntity> {
return this.repository.save(userToken);
}
async delete(userId: string, id: string): Promise<void> {
await this.repository.delete({ userId, id });
}
async deleteAll(userId: string): Promise<void> {
await this.userTokenRepository.delete({ user: { id: userId } });
await this.repository.delete({ userId });
}
}

View file

@ -6,7 +6,7 @@
"packages": {
"": {
"name": "immich",
"version": "1.53.0",
"version": "1.54.1",
"license": "UNLICENSED",
"dependencies": {
"@babel/runtime": "^7.20.13",
@ -48,7 +48,8 @@
"sanitize-filename": "^1.6.3",
"sharp": "^0.28.0",
"typeorm": "^0.3.11",
"typesense": "^1.5.3"
"typesense": "^1.5.3",
"ua-parser-js": "^1.0.35"
},
"bin": {
"immich": "bin/cli.sh"
@ -73,6 +74,7 @@
"@types/node": "^16.0.0",
"@types/sharp": "^0.30.2",
"@types/supertest": "^2.0.11",
"@types/ua-parser-js": "^0.7.36",
"@typescript-eslint/eslint-plugin": "^5.48.1",
"@typescript-eslint/parser": "^5.48.1",
"dotenv": "^14.2.0",
@ -2852,6 +2854,12 @@
"@types/node": "*"
}
},
"node_modules/@types/ua-parser-js": {
"version": "0.7.36",
"resolved": "https://registry.npmjs.org/@types/ua-parser-js/-/ua-parser-js-0.7.36.tgz",
"integrity": "sha512-N1rW+njavs70y2cApeIw1vLMYXRwfBy+7trgavGuuTfOd7j1Yh7QTRc/yqsPl6ncokt72ZXuxEU0PiCp9bSwNQ==",
"dev": true
},
"node_modules/@types/validator": {
"version": "13.7.14",
"resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.7.14.tgz",
@ -11207,6 +11215,24 @@
"@babel/runtime": "^7.17.2"
}
},
"node_modules/ua-parser-js": {
"version": "1.0.35",
"resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.35.tgz",
"integrity": "sha512-fKnGuqmTBnIE+/KXSzCn4db8RTigUzw1AN0DmdU6hJovUTbYJKyqj+8Mt1c4VfRDnOVJnENmfYkIPZ946UrSAA==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/ua-parser-js"
},
{
"type": "paypal",
"url": "https://paypal.me/faisalman"
}
],
"engines": {
"node": "*"
}
},
"node_modules/uglify-js": {
"version": "3.17.4",
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.17.4.tgz",
@ -13872,6 +13898,12 @@
"@types/node": "*"
}
},
"@types/ua-parser-js": {
"version": "0.7.36",
"resolved": "https://registry.npmjs.org/@types/ua-parser-js/-/ua-parser-js-0.7.36.tgz",
"integrity": "sha512-N1rW+njavs70y2cApeIw1vLMYXRwfBy+7trgavGuuTfOd7j1Yh7QTRc/yqsPl6ncokt72ZXuxEU0PiCp9bSwNQ==",
"dev": true
},
"@types/validator": {
"version": "13.7.14",
"resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.7.14.tgz",
@ -20132,6 +20164,11 @@
"loglevel": "^1.8.0"
}
},
"ua-parser-js": {
"version": "1.0.35",
"resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.35.tgz",
"integrity": "sha512-fKnGuqmTBnIE+/KXSzCn4db8RTigUzw1AN0DmdU6hJovUTbYJKyqj+8Mt1c4VfRDnOVJnENmfYkIPZ946UrSAA=="
},
"uglify-js": {
"version": "3.17.4",
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.17.4.tgz",

View file

@ -79,7 +79,8 @@
"sanitize-filename": "^1.6.3",
"sharp": "^0.28.0",
"typeorm": "^0.3.11",
"typesense": "^1.5.3"
"typesense": "^1.5.3",
"ua-parser-js": "^1.0.35"
},
"devDependencies": {
"@nestjs/cli": "^9.1.8",
@ -101,6 +102,7 @@
"@types/node": "^16.0.0",
"@types/sharp": "^0.30.2",
"@types/supertest": "^2.0.11",
"@types/ua-parser-js": "^0.7.36",
"@typescript-eslint/eslint-plugin": "^5.48.1",
"@typescript-eslint/parser": "^5.48.1",
"dotenv": "^14.2.0",
@ -139,9 +141,9 @@
"coverageThreshold": {
"./libs/domain/": {
"branches": 80,
"functions": 85,
"lines": 90,
"statements": 90
"functions": 88,
"lines": 94,
"statements": 94
}
},
"setupFilesAfterEnv": [
@ -158,4 +160,4 @@
},
"globalSetup": "<rootDir>/libs/domain/test/global-setup.js"
}
}
}

View file

@ -585,6 +585,49 @@ export const AssetTypeEnum = {
export type AssetTypeEnum = typeof AssetTypeEnum[keyof typeof AssetTypeEnum];
/**
*
* @export
* @interface AuthDeviceResponseDto
*/
export interface AuthDeviceResponseDto {
/**
*
* @type {string}
* @memberof AuthDeviceResponseDto
*/
'id': string;
/**
*
* @type {string}
* @memberof AuthDeviceResponseDto
*/
'createdAt': string;
/**
*
* @type {string}
* @memberof AuthDeviceResponseDto
*/
'updatedAt': string;
/**
*
* @type {boolean}
* @memberof AuthDeviceResponseDto
*/
'current': boolean;
/**
*
* @type {string}
* @memberof AuthDeviceResponseDto
*/
'deviceType': string;
/**
*
* @type {string}
* @memberof AuthDeviceResponseDto
*/
'deviceOS': string;
}
/**
*
* @export
@ -5951,6 +5994,41 @@ export const AuthenticationApiAxiosParamCreator = function (configuration?: Conf
options: localVarRequestOptions,
};
},
/**
*
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getAuthDevices: async (options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
const localVarPath = `/auth/devices`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication cookie required
// authentication bearer required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @param {LoginCredentialDto} loginCredentialDto
@ -6012,6 +6090,45 @@ export const AuthenticationApiAxiosParamCreator = function (configuration?: Conf
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @param {string} id
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
logoutAuthDevice: async (id: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'id' is not null or undefined
assertParamExists('logoutAuthDevice', 'id', id)
const localVarPath = `/auth/devices/{id}`
.replace(`{${"id"}}`, encodeURIComponent(String(id)));
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'DELETE', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication cookie required
// authentication bearer required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
@ -6086,6 +6203,15 @@ export const AuthenticationApiFp = function(configuration?: Configuration) {
const localVarAxiosArgs = await localVarAxiosParamCreator.changePassword(changePasswordDto, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async getAuthDevices(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<AuthDeviceResponseDto>>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getAuthDevices(options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {LoginCredentialDto} loginCredentialDto
@ -6105,6 +6231,16 @@ export const AuthenticationApiFp = function(configuration?: Configuration) {
const localVarAxiosArgs = await localVarAxiosParamCreator.logout(options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {string} id
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async logoutAuthDevice(id: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<void>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.logoutAuthDevice(id, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {*} [options] Override http request option.
@ -6142,6 +6278,14 @@ export const AuthenticationApiFactory = function (configuration?: Configuration,
changePassword(changePasswordDto: ChangePasswordDto, options?: any): AxiosPromise<UserResponseDto> {
return localVarFp.changePassword(changePasswordDto, options).then((request) => request(axios, basePath));
},
/**
*
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getAuthDevices(options?: any): AxiosPromise<Array<AuthDeviceResponseDto>> {
return localVarFp.getAuthDevices(options).then((request) => request(axios, basePath));
},
/**
*
* @param {LoginCredentialDto} loginCredentialDto
@ -6159,6 +6303,15 @@ export const AuthenticationApiFactory = function (configuration?: Configuration,
logout(options?: any): AxiosPromise<LogoutResponseDto> {
return localVarFp.logout(options).then((request) => request(axios, basePath));
},
/**
*
* @param {string} id
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
logoutAuthDevice(id: string, options?: any): AxiosPromise<void> {
return localVarFp.logoutAuthDevice(id, options).then((request) => request(axios, basePath));
},
/**
*
* @param {*} [options] Override http request option.
@ -6199,6 +6352,16 @@ export class AuthenticationApi extends BaseAPI {
return AuthenticationApiFp(this.configuration).changePassword(changePasswordDto, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof AuthenticationApi
*/
public getAuthDevices(options?: AxiosRequestConfig) {
return AuthenticationApiFp(this.configuration).getAuthDevices(options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {LoginCredentialDto} loginCredentialDto
@ -6220,6 +6383,17 @@ export class AuthenticationApi extends BaseAPI {
return AuthenticationApiFp(this.configuration).logout(options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {string} id
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof AuthenticationApi
*/
public logoutAuthDevice(id: string, options?: AxiosRequestConfig) {
return AuthenticationApiFp(this.configuration).logoutAuthDevice(id, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {*} [options] Override http request option.

View file

@ -0,0 +1,72 @@
<script lang="ts">
import { locale } from '$lib/stores/preferences.store';
import { AuthDeviceResponseDto } from '@api';
import { DateTime, ToRelativeCalendarOptions } from 'luxon';
import { createEventDispatcher } from 'svelte';
import Android from 'svelte-material-icons/Android.svelte';
import Apple from 'svelte-material-icons/Apple.svelte';
import AppleSafari from 'svelte-material-icons/AppleSafari.svelte';
import GoogleChrome from 'svelte-material-icons/GoogleChrome.svelte';
import Help from 'svelte-material-icons/Help.svelte';
import Linux from 'svelte-material-icons/Linux.svelte';
import MicrosoftWindows from 'svelte-material-icons/MicrosoftWindows.svelte';
import TrashCanOutline from 'svelte-material-icons/TrashCanOutline.svelte';
export let device: AuthDeviceResponseDto;
const dispatcher = createEventDispatcher();
const options: ToRelativeCalendarOptions = {
unit: 'days',
locale: $locale
};
</script>
<div class="flex flex-row w-full">
<!-- TODO: Device Image -->
<div
class="hidden sm:flex pr-2 justify-center items-center text-immich-primary dark:text-immich-dark-primary"
>
{#if device.deviceOS === 'Android'}
<Android size="40" />
{:else if device.deviceOS === 'iOS' || device.deviceOS === 'Mac OS'}
<Apple size="40" />
{:else if device.deviceOS.indexOf('Safari') !== -1}
<AppleSafari size="40" />
{:else if device.deviceOS.indexOf('Windows') !== -1}
<MicrosoftWindows size="40" />
{:else if device.deviceOS === 'Linux'}
<Linux size="40" />
{:else if device.deviceOS === 'Chromium OS' || device.deviceType === 'Chrome' || device.deviceType === 'Chromium'}
<GoogleChrome size="40" />
{:else}
<Help size="40" />
{/if}
</div>
<div class="pl-4 sm:pl-0 flex flex-row grow justify-between gap-1">
<div class="flex flex-col gap-1 justify-center dark:text-white">
<span class="text-sm">
{#if device.deviceType || device.deviceOS}
<span>{device.deviceOS || 'Unknown'}{device.deviceType || 'Unknown'}</span>
{:else}
<span>Unknown</span>
{/if}
</span>
<div class="text-sm">
<span class="">Last seen</span>
<span>{DateTime.fromISO(device.updatedAt).toRelativeCalendar(options)}</span>
</div>
</div>
{#if !device.current}
<div class="text-sm flex flex-col justify-center">
<button
on:click={() => dispatcher('delete')}
class="bg-immich-primary dark:bg-immich-dark-primary text-gray-100 dark:text-gray-700 rounded-full p-3 transition-all duration-150 hover:bg-immich-primary/75"
title="Logout"
>
<TrashCanOutline size="16" />
</button>
</div>
{/if}
</div>
</div>

View file

@ -0,0 +1,71 @@
<script lang="ts">
import { api, AuthDeviceResponseDto } from '@api';
import { onMount } from 'svelte';
import { handleError } from '../../utils/handle-error';
import ConfirmDialogue from '../shared-components/confirm-dialogue.svelte';
import {
notificationController,
NotificationType
} from '../shared-components/notification/notification';
import DeviceCard from './device-card.svelte';
let devices: AuthDeviceResponseDto[] = [];
let deleteDevice: AuthDeviceResponseDto | null = null;
const refresh = () => api.authenticationApi.getAuthDevices().then(({ data }) => (devices = data));
onMount(() => {
refresh();
});
$: currentDevice = devices.find((device) => device.current);
$: otherDevices = devices.filter((device) => !device.current);
const handleDelete = async () => {
if (!deleteDevice) {
return;
}
try {
await api.authenticationApi.logoutAuthDevice(deleteDevice.id);
notificationController.show({ message: `Logged out device`, type: NotificationType.Info });
} catch (error) {
handleError(error, 'Unable to logout device');
} finally {
await refresh();
deleteDevice = null;
}
};
</script>
{#if deleteDevice}
<ConfirmDialogue
prompt="Are you sure you want to logout this device?"
on:confirm={() => handleDelete()}
on:cancel={() => (deleteDevice = null)}
/>
{/if}
<section class="my-4">
{#if currentDevice}
<div class="mb-6">
<h3 class="font-medium text-xs mb-2 text-immich-primary dark:text-immich-dark-primary">
CURRENT DEVICE
</h3>
<DeviceCard device={currentDevice} />
</div>
{/if}
{#if otherDevices.length > 0}
<div>
<h3 class="font-medium text-xs mb-2 text-immich-primary dark:text-immich-dark-primary">
OTHER DEVICES
</h3>
{#each otherDevices as device, i}
<DeviceCard {device} on:delete={() => (deleteDevice = device)} />
{#if i !== otherDevices.length - 1}
<hr class="my-3" />
{/if}
{/each}
</div>
{/if}
</section>

View file

@ -6,6 +6,7 @@
import ChangePasswordSettings from './change-password-settings.svelte';
import OAuthSettings from './oauth-settings.svelte';
import UserAPIKeyList from './user-api-key-list.svelte';
import DeviceList from './device-list.svelte';
import UserProfileSettings from './user-profile-settings.svelte';
export let user: UserResponseDto;
@ -46,3 +47,7 @@
<OAuthSettings {user} />
</SettingAccordion>
{/if}
<SettingAccordion title="Authorized Devices" subtitle="View and manage your logged-in devices">
<DeviceList />
</SettingAccordion>