mirror of https://github.com/immich-app/immich.git
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>pull/2336/head
parent
aa91b946fa
commit
b8313abfa8
@ -0,0 +1,20 @@
|
||||
# openapi.model.AuthDeviceResponseDto
|
||||
|
||||
## Load the model package
|
||||
```dart
|
||||
import 'package:openapi/api.dart';
|
||||
```
|
||||
|
||||
## Properties
|
||||
Name | Type | Description | Notes
|
||||
------------ | ------------- | ------------- | -------------
|
||||
**id** | **String** | |
|
||||
**createdAt** | **String** | |
|
||||
**updatedAt** | **String** | |
|
||||
**current** | **bool** | |
|
||||
**deviceType** | **String** | |
|
||||
**deviceOS** | **String** | |
|
||||
|
||||
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
|
||||
|
||||
|
||||
@ -0,0 +1,151 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.12
|
||||
|
||||
// ignore_for_file: unused_element, unused_import
|
||||
// ignore_for_file: always_put_required_named_parameters_first
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
part of openapi.api;
|
||||
|
||||
class AuthDeviceResponseDto {
|
||||
/// Returns a new [AuthDeviceResponseDto] instance.
|
||||
AuthDeviceResponseDto({
|
||||
required this.id,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
required this.current,
|
||||
required this.deviceType,
|
||||
required this.deviceOS,
|
||||
});
|
||||
|
||||
String id;
|
||||
|
||||
String createdAt;
|
||||
|
||||
String updatedAt;
|
||||
|
||||
bool current;
|
||||
|
||||
String deviceType;
|
||||
|
||||
String deviceOS;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is AuthDeviceResponseDto &&
|
||||
other.id == id &&
|
||||
other.createdAt == createdAt &&
|
||||
other.updatedAt == updatedAt &&
|
||||
other.current == current &&
|
||||
other.deviceType == deviceType &&
|
||||
other.deviceOS == deviceOS;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(id.hashCode) +
|
||||
(createdAt.hashCode) +
|
||||
(updatedAt.hashCode) +
|
||||
(current.hashCode) +
|
||||
(deviceType.hashCode) +
|
||||
(deviceOS.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'AuthDeviceResponseDto[id=$id, createdAt=$createdAt, updatedAt=$updatedAt, current=$current, deviceType=$deviceType, deviceOS=$deviceOS]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'id'] = this.id;
|
||||
json[r'createdAt'] = this.createdAt;
|
||||
json[r'updatedAt'] = this.updatedAt;
|
||||
json[r'current'] = this.current;
|
||||
json[r'deviceType'] = this.deviceType;
|
||||
json[r'deviceOS'] = this.deviceOS;
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [AuthDeviceResponseDto] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static AuthDeviceResponseDto? fromJson(dynamic value) {
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
// Ensure that the map contains the required keys.
|
||||
// Note 1: the values aren't checked for validity beyond being non-null.
|
||||
// Note 2: this code is stripped in release mode!
|
||||
assert(() {
|
||||
requiredKeys.forEach((key) {
|
||||
assert(json.containsKey(key), 'Required key "AuthDeviceResponseDto[$key]" is missing from JSON.');
|
||||
assert(json[key] != null, 'Required key "AuthDeviceResponseDto[$key]" has a null value in JSON.');
|
||||
});
|
||||
return true;
|
||||
}());
|
||||
|
||||
return AuthDeviceResponseDto(
|
||||
id: mapValueOfType<String>(json, r'id')!,
|
||||
createdAt: mapValueOfType<String>(json, r'createdAt')!,
|
||||
updatedAt: mapValueOfType<String>(json, r'updatedAt')!,
|
||||
current: mapValueOfType<bool>(json, r'current')!,
|
||||
deviceType: mapValueOfType<String>(json, r'deviceType')!,
|
||||
deviceOS: mapValueOfType<String>(json, r'deviceOS')!,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<AuthDeviceResponseDto>? listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <AuthDeviceResponseDto>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = AuthDeviceResponseDto.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, AuthDeviceResponseDto> mapFromJson(dynamic json) {
|
||||
final map = <String, AuthDeviceResponseDto>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = AuthDeviceResponseDto.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of AuthDeviceResponseDto-objects as value to a dart map
|
||||
static Map<String, List<AuthDeviceResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<AuthDeviceResponseDto>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = AuthDeviceResponseDto.listFromJson(entry.value, growable: growable,);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'id',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
'current',
|
||||
'deviceType',
|
||||
'deviceOS',
|
||||
};
|
||||
}
|
||||
|
||||
@ -0,0 +1,52 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.12
|
||||
|
||||
// ignore_for_file: unused_element, unused_import
|
||||
// ignore_for_file: always_put_required_named_parameters_first
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
import 'package:openapi/api.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
// tests for AuthDeviceResponseDto
|
||||
void main() {
|
||||
// final instance = AuthDeviceResponseDto();
|
||||
|
||||
group('test AuthDeviceResponseDto', () {
|
||||
// String id
|
||||
test('to test the property `id`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
// String createdAt
|
||||
test('to test the property `createdAt`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
// String updatedAt
|
||||
test('to test the property `updatedAt`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
// bool current
|
||||
test('to test the property `current`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
// String deviceType
|
||||
test('to test the property `deviceType`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
// String deviceOS
|
||||
test('to test the property `deviceOS`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
|
||||
}
|
||||
@ -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 || '',
|
||||
};
|
||||
});
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
export * from './auth.constant';
|
||||
export * from './auth.core';
|
||||
export * from './auth.service';
|
||||
export * from './dto';
|
||||
export * from './response-dto';
|
||||
|
||||
@ -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,
|
||||
});
|
||||
@ -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';
|
||||
|
||||
@ -1,13 +1,4 @@
|
||||
import { ApiResponseProperty } from '@nestjs/swagger';
|
||||
|
||||
export class LogoutResponseDto {
|
||||
constructor(successful: boolean) {
|
||||
this.successful = successful;
|
||||
}
|
||||
|
||||
@ApiResponseProperty()
|
||||
successful!: boolean;
|
||||
|
||||
@ApiResponseProperty()
|
||||
redirectUri!: string;
|
||||
}
|
||||
|
||||
@ -1,10 +1,3 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class ValidateAccessTokenResponseDto {
|
||||
constructor(authStatus: boolean) {
|
||||
this.authStatus = authStatus;
|
||||
}
|
||||
|
||||
@ApiProperty({ type: 'boolean' })
|
||||
authStatus!: boolean;
|
||||
}
|
||||
|
||||
@ -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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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"`);
|
||||
}
|
||||
|
||||
}
|
||||
@ -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>
|
||||
@ -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>
|
||||
Loading…
Reference in New Issue