1
0
mirror of https://github.com/immich-app/immich.git synced 2024-12-25 10:43:13 +02:00

feat(web,server): api keys (#1244)

* feat(server): api keys

* chore: open-api

* feat(web): api keys

* fix: remove keys when deleting a user
This commit is contained in:
Jason Rasmussen 2023-01-02 15:22:33 -05:00 committed by GitHub
parent 9edbff0ec0
commit 9e6d6b2532
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
51 changed files with 1366 additions and 24 deletions

View File

@ -3,6 +3,11 @@
.travis.yml
README.md
analysis_options.yaml
doc/APIKeyApi.md
doc/APIKeyCreateDto.md
doc/APIKeyCreateResponseDto.md
doc/APIKeyResponseDto.md
doc/APIKeyUpdateDto.md
doc/AddAssetsDto.md
doc/AddAssetsResponseDto.md
doc/AddUsersDto.md
@ -85,6 +90,7 @@ doc/ValidateAccessTokenResponseDto.md
git_push.sh
lib/api.dart
lib/api/album_api.dart
lib/api/api_key_api.dart
lib/api/asset_api.dart
lib/api/authentication_api.dart
lib/api/device_info_api.dart
@ -109,6 +115,10 @@ lib/model/admin_signup_response_dto.dart
lib/model/album_count_response_dto.dart
lib/model/album_response_dto.dart
lib/model/all_job_status_response_dto.dart
lib/model/api_key_create_dto.dart
lib/model/api_key_create_response_dto.dart
lib/model/api_key_response_dto.dart
lib/model/api_key_update_dto.dart
lib/model/asset_count_by_time_bucket.dart
lib/model/asset_count_by_time_bucket_response_dto.dart
lib/model/asset_count_by_user_id_response_dto.dart
@ -180,6 +190,11 @@ test/album_api_test.dart
test/album_count_response_dto_test.dart
test/album_response_dto_test.dart
test/all_job_status_response_dto_test.dart
test/api_key_api_test.dart
test/api_key_create_dto_test.dart
test/api_key_create_response_dto_test.dart
test/api_key_response_dto_test.dart
test/api_key_update_dto_test.dart
test/asset_api_test.dart
test/asset_count_by_time_bucket_response_dto_test.dart
test/asset_count_by_time_bucket_test.dart

BIN
mobile/openapi/README.md generated

Binary file not shown.

BIN
mobile/openapi/doc/APIKeyApi.md generated Normal file

Binary file not shown.

BIN
mobile/openapi/doc/APIKeyCreateDto.md generated Normal file

Binary file not shown.

Binary file not shown.

BIN
mobile/openapi/doc/APIKeyResponseDto.md generated Normal file

Binary file not shown.

BIN
mobile/openapi/doc/APIKeyUpdateDto.md generated Normal file

Binary file not shown.

Binary file not shown.

BIN
mobile/openapi/lib/api/api_key_api.dart generated Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
mobile/openapi/test/api_key_api_test.dart generated Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,48 @@
import { Body, Controller, Delete, Get, Param, ParseIntPipe, Post, Put, ValidationPipe } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
import { Authenticated } from '../../decorators/authenticated.decorator';
import { APIKeyService } from './api-key.service';
import { APIKeyCreateDto } from './dto/api-key-create.dto';
import { APIKeyUpdateDto } from './dto/api-key-update.dto';
import { APIKeyCreateResponseDto } from './repsonse-dto/api-key-create-response.dto';
import { APIKeyResponseDto } from './repsonse-dto/api-key-response.dto';
@ApiTags('API Key')
@Controller('api-key')
@Authenticated()
export class APIKeyController {
constructor(private service: APIKeyService) {}
@Post()
createKey(
@GetAuthUser() authUser: AuthUserDto,
@Body(ValidationPipe) dto: APIKeyCreateDto,
): Promise<APIKeyCreateResponseDto> {
return this.service.create(authUser, dto);
}
@Get()
getKeys(@GetAuthUser() authUser: AuthUserDto): Promise<APIKeyResponseDto[]> {
return this.service.getAll(authUser);
}
@Get(':id')
getKey(@GetAuthUser() authUser: AuthUserDto, @Param('id', ParseIntPipe) id: number): Promise<APIKeyResponseDto> {
return this.service.getById(authUser, id);
}
@Put(':id')
updateKey(
@GetAuthUser() authUser: AuthUserDto,
@Param('id', ParseIntPipe) id: number,
@Body(ValidationPipe) dto: APIKeyUpdateDto,
): Promise<APIKeyResponseDto> {
return this.service.update(authUser, id, dto);
}
@Delete(':id')
deleteKey(@GetAuthUser() authUser: AuthUserDto, @Param('id', ParseIntPipe) id: number): Promise<void> {
return this.service.delete(authUser, id);
}
}

View File

@ -0,0 +1,16 @@
import { APIKeyEntity } from '@app/database';
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { APIKeyController } from './api-key.controller';
import { APIKeyRepository, IKeyRepository } from './api-key.repository';
import { APIKeyService } from './api-key.service';
const KEY_REPOSITORY = { provide: IKeyRepository, useClass: APIKeyRepository };
@Module({
imports: [TypeOrmModule.forFeature([APIKeyEntity])],
controllers: [APIKeyController],
providers: [APIKeyService, KEY_REPOSITORY],
exports: [APIKeyService, KEY_REPOSITORY],
})
export class APIKeyModule {}

View File

@ -0,0 +1,59 @@
import { APIKeyEntity } from '@app/database';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
export const IKeyRepository = 'IKeyRepository';
export interface IKeyRepository {
create(dto: Partial<APIKeyEntity>): Promise<APIKeyEntity>;
update(userId: string, id: number, dto: Partial<APIKeyEntity>): Promise<APIKeyEntity>;
delete(userId: string, id: number): Promise<void>;
/**
* Includes the hashed `key` for verification
* @param id
*/
getKey(id: number): Promise<APIKeyEntity | null>;
getById(userId: string, id: number): Promise<APIKeyEntity | null>;
getByUserId(userId: string): Promise<APIKeyEntity[]>;
}
@Injectable()
export class APIKeyRepository implements IKeyRepository {
constructor(@InjectRepository(APIKeyEntity) private repository: Repository<APIKeyEntity>) {}
async create(dto: Partial<APIKeyEntity>): Promise<APIKeyEntity> {
return this.repository.save(dto);
}
async update(userId: string, id: number, dto: Partial<APIKeyEntity>): Promise<APIKeyEntity> {
await this.repository.update({ userId, id }, dto);
return this.repository.findOneOrFail({ where: { id: dto.id } });
}
async delete(userId: string, id: number): Promise<void> {
await this.repository.delete({ userId, id });
}
getKey(id: number): Promise<APIKeyEntity | null> {
return this.repository.findOne({
select: {
id: true,
key: true,
userId: true,
},
where: { id },
relations: {
user: true,
},
});
}
getById(userId: string, id: number): Promise<APIKeyEntity | null> {
return this.repository.findOne({ where: { userId, id } });
}
getByUserId(userId: string): Promise<APIKeyEntity[]> {
return this.repository.find({ where: { userId }, order: { createdAt: 'DESC' } });
}
}

View File

@ -0,0 +1,74 @@
import { UserEntity } from '@app/database';
import { BadRequestException, Inject, Injectable, UnauthorizedException } from '@nestjs/common';
import { compareSync, hash } from 'bcrypt';
import { randomBytes } from 'node:crypto';
import { AuthUserDto } from '../../decorators/auth-user.decorator';
import { IKeyRepository } from './api-key.repository';
import { APIKeyCreateDto } from './dto/api-key-create.dto';
import { APIKeyCreateResponseDto } from './repsonse-dto/api-key-create-response.dto';
import { APIKeyResponseDto, mapKey } from './repsonse-dto/api-key-response.dto';
@Injectable()
export class APIKeyService {
constructor(@Inject(IKeyRepository) private repository: IKeyRepository) {}
async create(authUser: AuthUserDto, dto: APIKeyCreateDto): Promise<APIKeyCreateResponseDto> {
const key = randomBytes(24).toString('base64').replace(/\W/g, '');
const entity = await this.repository.create({
key: await hash(key, 10),
name: dto.name || 'API Key',
userId: authUser.id,
});
const secret = Buffer.from(`${entity.id}:${key}`, 'utf8').toString('base64');
return { secret, apiKey: mapKey(entity) };
}
async update(authUser: AuthUserDto, id: number, dto: APIKeyCreateDto): Promise<APIKeyResponseDto> {
const exists = await this.repository.getById(authUser.id, id);
if (!exists) {
throw new BadRequestException('API Key not found');
}
return this.repository.update(authUser.id, id, {
name: dto.name,
});
}
async delete(authUser: AuthUserDto, id: number): Promise<void> {
const exists = await this.repository.getById(authUser.id, id);
if (!exists) {
throw new BadRequestException('API Key not found');
}
await this.repository.delete(authUser.id, id);
}
async getById(authUser: AuthUserDto, id: number): Promise<APIKeyResponseDto> {
const key = await this.repository.getById(authUser.id, id);
if (!key) {
throw new BadRequestException('API Key not found');
}
return mapKey(key);
}
async getAll(authUser: AuthUserDto): Promise<APIKeyResponseDto[]> {
const keys = await this.repository.getByUserId(authUser.id);
return keys.map(mapKey);
}
async validate(token: string): Promise<UserEntity> {
const [_id, key] = Buffer.from(token, 'base64').toString('utf8').split(':');
const id = Number(_id);
if (id && key) {
const entity = await this.repository.getKey(id);
if (entity?.user && entity?.key && compareSync(key, entity.key)) {
return entity.user as UserEntity;
}
}
throw new UnauthorizedException('Invalid API Key');
}
}

View File

@ -0,0 +1,8 @@
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
export class APIKeyCreateDto {
@IsString()
@IsNotEmpty()
@IsOptional()
name?: string;
}

View File

@ -0,0 +1,7 @@
import { IsNotEmpty, IsString } from 'class-validator';
export class APIKeyUpdateDto {
@IsString()
@IsNotEmpty()
name!: string;
}

View File

@ -0,0 +1,6 @@
import { APIKeyResponseDto } from './api-key-response.dto';
export class APIKeyCreateResponseDto {
secret!: string;
apiKey!: APIKeyResponseDto;
}

View File

@ -0,0 +1,17 @@
import { APIKeyEntity } from '@app/database';
export class APIKeyResponseDto {
id!: number;
name!: string;
createdAt!: string;
updatedAt!: string;
}
export function mapKey(entity: APIKeyEntity): APIKeyResponseDto {
return {
id: entity.id,
name: entity.name,
createdAt: entity.createdAt,
updatedAt: entity.updatedAt,
};
}

View File

@ -19,6 +19,7 @@ import { JobModule } from './api-v1/job/job.module';
import { SystemConfigModule } from './api-v1/system-config/system-config.module';
import { OAuthModule } from './api-v1/oauth/oauth.module';
import { TagModule } from './api-v1/tag/tag.module';
import { APIKeyModule } from './api-v1/api-key/api-key.module';
@Module({
imports: [
@ -27,6 +28,8 @@ import { TagModule } from './api-v1/tag/tag.module';
DatabaseModule,
UserModule,
APIKeyModule,
AssetModule,
AuthModule,

View File

@ -1,13 +1,13 @@
import { UseGuards } from '@nestjs/common';
import { AdminRolesGuard } from '../middlewares/admin-role-guard.middleware';
import { JwtAuthGuard } from '../modules/immich-jwt/guards/jwt-auth.guard';
import { AuthGuard } from '../modules/immich-jwt/guards/auth.guard';
interface AuthenticatedOptions {
admin?: boolean;
}
export const Authenticated = (options?: AuthenticatedOptions) => {
const guards: Parameters<typeof UseGuards> = [JwtAuthGuard];
const guards: Parameters<typeof UseGuards> = [AuthGuard];
options = options || {};
if (options.admin) {
guards.push(AdminRolesGuard);

View File

@ -1,12 +1,17 @@
import { CanActivate, ExecutionContext, Injectable, Logger } from '@nestjs/common';
import { Request } from 'express';
import { UserResponseDto } from '../api-v1/user/response-dto/user-response.dto';
interface UserRequest extends Request {
user: UserResponseDto;
}
@Injectable()
export class AdminRolesGuard implements CanActivate {
logger = new Logger(AdminRolesGuard.name);
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request>();
const request = context.switchToHttp().getRequest<UserRequest>();
const isAdmin = request.user?.isAdmin || false;
if (!isAdmin) {
this.logger.log(`Denied access to admin only route: ${request.path}`);

View File

@ -0,0 +1,7 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard as PassportAuthGuard } from '@nestjs/passport';
import { API_KEY_STRATEGY } from '../strategies/api-key.strategy';
import { JWT_STRATEGY } from '../strategies/jwt.strategy';
@Injectable()
export class AuthGuard extends PassportAuthGuard([JWT_STRATEGY, API_KEY_STRATEGY]) {}

View File

@ -1,5 +0,0 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}

View File

@ -5,10 +5,12 @@ import { jwtConfig } from '../../config/jwt.config';
import { JwtStrategy } from './strategies/jwt.strategy';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserEntity } from '@app/database';
import { APIKeyModule } from '../../api-v1/api-key/api-key.module';
import { APIKeyStrategy } from './strategies/api-key.strategy';
@Module({
imports: [JwtModule.register(jwtConfig), TypeOrmModule.forFeature([UserEntity])],
providers: [ImmichJwtService, JwtStrategy],
imports: [JwtModule.register(jwtConfig), TypeOrmModule.forFeature([UserEntity]), APIKeyModule],
providers: [ImmichJwtService, JwtStrategy, APIKeyStrategy],
exports: [ImmichJwtService],
})
export class ImmichJwtModule {}

View File

@ -0,0 +1,21 @@
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { IStrategyOptions, Strategy } from 'passport-http-header-strategy';
import { APIKeyService } from '../../../api-v1/api-key/api-key.service';
export const API_KEY_STRATEGY = 'api-key';
const options: IStrategyOptions = {
header: 'x-api-key',
};
@Injectable()
export class APIKeyStrategy extends PassportStrategy(Strategy, API_KEY_STRATEGY) {
constructor(private apiKeyService: APIKeyService) {
super(options);
}
async validate(token: string) {
return this.apiKeyService.validate(token);
}
}

View File

@ -1,15 +1,17 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { InjectRepository } from '@nestjs/typeorm';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { ExtractJwt, Strategy, StrategyOptions } from 'passport-jwt';
import { Repository } from 'typeorm';
import { JwtPayloadDto } from '../../../api-v1/auth/dto/jwt-payload.dto';
import { UserEntity } from '@app/database';
import { jwtSecret } from '../../../constants/jwt.constant';
import { ImmichJwtService } from '../immich-jwt.service';
export const JWT_STRATEGY = 'jwt';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
export class JwtStrategy extends PassportStrategy(Strategy, JWT_STRATEGY) {
constructor(
@InjectRepository(UserEntity)
private usersRepository: Repository<UserEntity>,
@ -22,7 +24,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
]),
ignoreExpiration: false,
secretOrKey: jwtSecret,
});
} as StrategyOptions);
}
async validate(payload: JwtPayloadDto) {

View File

@ -3,7 +3,7 @@ import { TestingModuleBuilder } from '@nestjs/testing';
import { DataSource } from 'typeorm';
import { IUserRepository } from '../src/api-v1/user/user-repository';
import { AuthUserDto } from '../src/decorators/auth-user.decorator';
import { JwtAuthGuard } from '../src/modules/immich-jwt/guards/jwt-auth.guard';
import { AuthGuard } from '../src/modules/immich-jwt/guards/auth.guard';
type CustomAuthCallback = () => AuthUserDto;
@ -49,5 +49,5 @@ export function authCustom(builder: TestingModuleBuilder, callback: CustomAuthCa
return true;
},
};
return builder.overrideGuard(JwtAuthGuard).useValue(canActivate);
return builder.overrideGuard(AuthGuard).useValue(canActivate);
}

View File

@ -1,5 +1,5 @@
import { immichAppConfig, immichBullAsyncConfig } from '@app/common/config';
import { DatabaseModule, AssetEntity, ExifEntity, SmartInfoEntity, UserEntity } from '@app/database';
import { DatabaseModule, AssetEntity, ExifEntity, SmartInfoEntity, UserEntity, APIKeyEntity } from '@app/database';
import { StorageModule } from '@app/storage';
import { BullModule } from '@nestjs/bull';
import { Module } from '@nestjs/common';
@ -23,7 +23,7 @@ import { immichSharedQueues } from '@app/job/constants/bull-queue-registration.c
ConfigModule.forRoot(immichAppConfig),
DatabaseModule,
ImmichConfigModule,
TypeOrmModule.forFeature([UserEntity, ExifEntity, AssetEntity, SmartInfoEntity]),
TypeOrmModule.forFeature([UserEntity, ExifEntity, AssetEntity, SmartInfoEntity, APIKeyEntity]),
StorageModule,
BullModule.forRootAsync(immichBullAsyncConfig),
BullModule.registerQueue(...immichSharedQueues),

View File

@ -1,5 +1,5 @@
import { APP_UPLOAD_LOCATION, userUtils } from '@app/common';
import { AssetEntity, UserEntity } from '@app/database';
import { APIKeyEntity, AssetEntity, UserEntity } from '@app/database';
import { QueueNameEnum, userDeletionProcessorName } from '@app/job';
import { IUserDeletionJob } from '@app/job/interfaces/user-deletion.interface';
import { Process, Processor } from '@nestjs/bull';
@ -17,6 +17,9 @@ export class UserDeletionProcessor {
@InjectRepository(AssetEntity)
private assetRepository: Repository<AssetEntity>,
@InjectRepository(APIKeyEntity)
private apiKeyRepository: Repository<APIKeyEntity>,
) {}
@Process(userDeletionProcessorName)
@ -27,6 +30,7 @@ export class UserDeletionProcessor {
const basePath = APP_UPLOAD_LOCATION;
const userAssetDir = join(basePath, user.id);
fs.rmSync(userAssetDir, { recursive: true, force: true });
await this.apiKeyRepository.delete({ userId: user.id });
await this.assetRepository.delete({ userId: user.id });
await this.userRepository.remove(user);
}

View File

@ -331,6 +331,148 @@
]
}
},
"/api-key": {
"post": {
"operationId": "createKey",
"parameters": [],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/APIKeyCreateDto"
}
}
}
},
"responses": {
"201": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/APIKeyCreateResponseDto"
}
}
}
}
},
"tags": [
"API Key"
]
},
"get": {
"operationId": "getKeys",
"parameters": [],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/APIKeyResponseDto"
}
}
}
}
}
},
"tags": [
"API Key"
]
}
},
"/api-key/{id}": {
"get": {
"operationId": "getKey",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/APIKeyResponseDto"
}
}
}
}
},
"tags": [
"API Key"
]
},
"put": {
"operationId": "updateKey",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/APIKeyUpdateDto"
}
}
}
},
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/APIKeyResponseDto"
}
}
}
}
},
"tags": [
"API Key"
]
},
"delete": {
"operationId": "deleteKey",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
}
],
"responses": {
"200": {
"description": ""
}
},
"tags": [
"API Key"
]
}
},
"/asset/upload": {
"post": {
"operationId": "uploadFile",
@ -2467,6 +2609,63 @@
"profileImagePath"
]
},
"APIKeyCreateDto": {
"type": "object",
"properties": {
"name": {
"type": "string"
}
}
},
"APIKeyResponseDto": {
"type": "object",
"properties": {
"id": {
"type": "number"
},
"name": {
"type": "string"
},
"createdAt": {
"type": "string"
},
"updatedAt": {
"type": "string"
}
},
"required": [
"id",
"name",
"createdAt",
"updatedAt"
]
},
"APIKeyCreateResponseDto": {
"type": "object",
"properties": {
"secret": {
"type": "string"
},
"apiKey": {
"$ref": "#/components/schemas/APIKeyResponseDto"
}
},
"required": [
"secret",
"apiKey"
]
},
"APIKeyUpdateDto": {
"type": "object",
"properties": {
"name": {
"type": "string"
}
},
"required": [
"name"
]
},
"AssetFileUploadDto": {
"type": "object",
"properties": {

View File

@ -0,0 +1,26 @@
import { Column, CreateDateColumn, Entity, ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm';
import { UserEntity } from './user.entity';
@Entity('api_keys')
export class APIKeyEntity {
@PrimaryGeneratedColumn()
id!: number;
@Column()
name!: string;
@Column({ select: false })
key?: string;
@Column()
userId!: string;
@ManyToOne(() => UserEntity)
user?: UserEntity;
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: string;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt!: string;
}

View File

@ -1,4 +1,5 @@
export * from './album.entity';
export * from './api-key.entity';
export * from './asset-album.entity';
export * from './asset.entity';
export * from './device-info.entity';

View File

@ -0,0 +1,16 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddAPIKeys1672502270115 implements MigrationInterface {
name = 'AddAPIKeys1672502270115'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE TABLE "api_keys" ("id" SERIAL NOT NULL, "name" character varying NOT NULL, "key" character varying NOT NULL, "userId" uuid NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_5c8a79801b44bd27b79228e1dad" PRIMARY KEY ("id"))`);
await queryRunner.query(`ALTER TABLE "api_keys" ADD CONSTRAINT "FK_6c2e267ae764a9413b863a29342" 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 "api_keys" DROP CONSTRAINT "FK_6c2e267ae764a9413b863a29342"`);
await queryRunner.query(`DROP TABLE "api_keys"`);
}
}

View File

@ -47,6 +47,7 @@
"nest-commander": "^3.3.0",
"openid-client": "^5.2.1",
"passport": "^0.6.0",
"passport-http-header-strategy": "^1.1.0",
"passport-jwt": "^4.0.0",
"pg": "^8.7.1",
"redis": "^3.1.2",
@ -2377,9 +2378,9 @@
}
},
"node_modules/@types/inquirer": {
"version": "8.2.4",
"resolved": "https://registry.npmjs.org/@types/inquirer/-/inquirer-8.2.4.tgz",
"integrity": "sha512-Pxxx3i3AyK7vKAj3LRM/vF7ETcHKiLJ/u5CnNgbz/eYj/vB3xGAYtRxI5IKtq0hpe5iFHD22BKV3n6WHUu0k4Q==",
"version": "8.2.5",
"resolved": "https://registry.npmjs.org/@types/inquirer/-/inquirer-8.2.5.tgz",
"integrity": "sha512-QXlzybid60YtAwfgG3cpykptRYUx2KomzNutMlWsQC64J/WG/gQSl+P4w7A21sGN0VIxRVava4rgnT7FQmFCdg==",
"peer": true,
"dependencies": {
"@types/through": "*"
@ -8618,6 +8619,14 @@
"url": "https://github.com/sponsors/jaredhanson"
}
},
"node_modules/passport-http-header-strategy": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/passport-http-header-strategy/-/passport-http-header-strategy-1.1.0.tgz",
"integrity": "sha512-Gn60rR55UE1wXbVhnnfG3yyeRSz5pzz3n6rppxa6xiOo4gGPh/onuw29HuGjpk9DSzXRFkJn95+8RT11kXHeWA==",
"dependencies": {
"passport-strategy": "^1.0.0"
}
},
"node_modules/passport-jwt": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/passport-jwt/-/passport-jwt-4.0.0.tgz",
@ -9848,6 +9857,7 @@
"version": "1.4.8",
"resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz",
"integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==",
"deprecated": "Please use @jridgewell/sourcemap-codec instead",
"dev": true
},
"node_modules/spawn-command": {
@ -13079,9 +13089,9 @@
}
},
"@types/inquirer": {
"version": "8.2.4",
"resolved": "https://registry.npmjs.org/@types/inquirer/-/inquirer-8.2.4.tgz",
"integrity": "sha512-Pxxx3i3AyK7vKAj3LRM/vF7ETcHKiLJ/u5CnNgbz/eYj/vB3xGAYtRxI5IKtq0hpe5iFHD22BKV3n6WHUu0k4Q==",
"version": "8.2.5",
"resolved": "https://registry.npmjs.org/@types/inquirer/-/inquirer-8.2.5.tgz",
"integrity": "sha512-QXlzybid60YtAwfgG3cpykptRYUx2KomzNutMlWsQC64J/WG/gQSl+P4w7A21sGN0VIxRVava4rgnT7FQmFCdg==",
"peer": true,
"requires": {
"@types/through": "*"
@ -17917,6 +17927,14 @@
"utils-merge": "^1.0.1"
}
},
"passport-http-header-strategy": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/passport-http-header-strategy/-/passport-http-header-strategy-1.1.0.tgz",
"integrity": "sha512-Gn60rR55UE1wXbVhnnfG3yyeRSz5pzz3n6rppxa6xiOo4gGPh/onuw29HuGjpk9DSzXRFkJn95+8RT11kXHeWA==",
"requires": {
"passport-strategy": "^1.0.0"
}
},
"passport-jwt": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/passport-jwt/-/passport-jwt-4.0.0.tgz",

View File

@ -70,6 +70,7 @@
"nest-commander": "^3.3.0",
"openid-client": "^5.2.1",
"passport": "^0.6.0",
"passport-http-header-strategy": "^1.1.0",
"passport-jwt": "^4.0.0",
"pg": "^8.7.1",
"redis": "^3.1.2",

View File

@ -1,6 +1,7 @@
import { env } from '$env/dynamic/public';
import {
AlbumApi,
APIKeyApi,
AssetApi,
AuthenticationApi,
Configuration,
@ -21,6 +22,7 @@ class ImmichApi {
public deviceInfoApi: DeviceInfoApi;
public serverInfoApi: ServerInfoApi;
public jobApi: JobApi;
public keyApi: APIKeyApi;
public systemConfigApi: SystemConfigApi;
private config = new Configuration({ basePath: '/api' });
@ -34,6 +36,7 @@ class ImmichApi {
this.deviceInfoApi = new DeviceInfoApi(this.config);
this.serverInfoApi = new ServerInfoApi(this.config);
this.jobApi = new JobApi(this.config);
this.keyApi = new APIKeyApi(this.config);
this.systemConfigApi = new SystemConfigApi(this.config);
}

View File

@ -21,6 +21,82 @@ import { DUMMY_BASE_URL, assertParamExists, setApiKeyToObject, setBasicAuthToObj
// @ts-ignore
import { BASE_PATH, COLLECTION_FORMATS, RequestArgs, BaseAPI, RequiredError } from './base';
/**
*
* @export
* @interface APIKeyCreateDto
*/
export interface APIKeyCreateDto {
/**
*
* @type {string}
* @memberof APIKeyCreateDto
*/
'name'?: string;
}
/**
*
* @export
* @interface APIKeyCreateResponseDto
*/
export interface APIKeyCreateResponseDto {
/**
*
* @type {string}
* @memberof APIKeyCreateResponseDto
*/
'secret': string;
/**
*
* @type {APIKeyResponseDto}
* @memberof APIKeyCreateResponseDto
*/
'apiKey': APIKeyResponseDto;
}
/**
*
* @export
* @interface APIKeyResponseDto
*/
export interface APIKeyResponseDto {
/**
*
* @type {number}
* @memberof APIKeyResponseDto
*/
'id': number;
/**
*
* @type {string}
* @memberof APIKeyResponseDto
*/
'name': string;
/**
*
* @type {string}
* @memberof APIKeyResponseDto
*/
'createdAt': string;
/**
*
* @type {string}
* @memberof APIKeyResponseDto
*/
'updatedAt': string;
}
/**
*
* @export
* @interface APIKeyUpdateDto
*/
export interface APIKeyUpdateDto {
/**
*
* @type {string}
* @memberof APIKeyUpdateDto
*/
'name': string;
}
/**
*
* @export
@ -1990,6 +2066,363 @@ export interface ValidateAccessTokenResponseDto {
'authStatus': boolean;
}
/**
* APIKeyApi - axios parameter creator
* @export
*/
export const APIKeyApiAxiosParamCreator = function (configuration?: Configuration) {
return {
/**
*
* @param {APIKeyCreateDto} aPIKeyCreateDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
createKey: async (aPIKeyCreateDto: APIKeyCreateDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'aPIKeyCreateDto' is not null or undefined
assertParamExists('createKey', 'aPIKeyCreateDto', aPIKeyCreateDto)
const localVarPath = `/api-key`;
// 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: 'POST', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
localVarHeaderParameter['Content-Type'] = 'application/json';
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
localVarRequestOptions.data = serializeDataIfNeeded(aPIKeyCreateDto, localVarRequestOptions, configuration)
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @param {number} id
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
deleteKey: async (id: number, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'id' is not null or undefined
assertParamExists('deleteKey', 'id', id)
const localVarPath = `/api-key/{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;
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @param {number} id
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getKey: async (id: number, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'id' is not null or undefined
assertParamExists('getKey', 'id', id)
const localVarPath = `/api-key/{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: 'GET', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getKeys: async (options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
const localVarPath = `/api-key`;
// 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;
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @param {number} id
* @param {APIKeyUpdateDto} aPIKeyUpdateDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
updateKey: async (id: number, aPIKeyUpdateDto: APIKeyUpdateDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'id' is not null or undefined
assertParamExists('updateKey', 'id', id)
// verify required parameter 'aPIKeyUpdateDto' is not null or undefined
assertParamExists('updateKey', 'aPIKeyUpdateDto', aPIKeyUpdateDto)
const localVarPath = `/api-key/{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: 'PUT', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
localVarHeaderParameter['Content-Type'] = 'application/json';
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
localVarRequestOptions.data = serializeDataIfNeeded(aPIKeyUpdateDto, localVarRequestOptions, configuration)
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
}
};
/**
* APIKeyApi - functional programming interface
* @export
*/
export const APIKeyApiFp = function(configuration?: Configuration) {
const localVarAxiosParamCreator = APIKeyApiAxiosParamCreator(configuration)
return {
/**
*
* @param {APIKeyCreateDto} aPIKeyCreateDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async createKey(aPIKeyCreateDto: APIKeyCreateDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<APIKeyCreateResponseDto>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.createKey(aPIKeyCreateDto, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {number} id
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async deleteKey(id: number, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<void>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.deleteKey(id, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {number} id
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async getKey(id: number, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<APIKeyResponseDto>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getKey(id, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async getKeys(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<APIKeyResponseDto>>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getKeys(options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {number} id
* @param {APIKeyUpdateDto} aPIKeyUpdateDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async updateKey(id: number, aPIKeyUpdateDto: APIKeyUpdateDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<APIKeyResponseDto>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.updateKey(id, aPIKeyUpdateDto, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
}
};
/**
* APIKeyApi - factory interface
* @export
*/
export const APIKeyApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) {
const localVarFp = APIKeyApiFp(configuration)
return {
/**
*
* @param {APIKeyCreateDto} aPIKeyCreateDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
createKey(aPIKeyCreateDto: APIKeyCreateDto, options?: any): AxiosPromise<APIKeyCreateResponseDto> {
return localVarFp.createKey(aPIKeyCreateDto, options).then((request) => request(axios, basePath));
},
/**
*
* @param {number} id
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
deleteKey(id: number, options?: any): AxiosPromise<void> {
return localVarFp.deleteKey(id, options).then((request) => request(axios, basePath));
},
/**
*
* @param {number} id
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getKey(id: number, options?: any): AxiosPromise<APIKeyResponseDto> {
return localVarFp.getKey(id, options).then((request) => request(axios, basePath));
},
/**
*
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getKeys(options?: any): AxiosPromise<Array<APIKeyResponseDto>> {
return localVarFp.getKeys(options).then((request) => request(axios, basePath));
},
/**
*
* @param {number} id
* @param {APIKeyUpdateDto} aPIKeyUpdateDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
updateKey(id: number, aPIKeyUpdateDto: APIKeyUpdateDto, options?: any): AxiosPromise<APIKeyResponseDto> {
return localVarFp.updateKey(id, aPIKeyUpdateDto, options).then((request) => request(axios, basePath));
},
};
};
/**
* APIKeyApi - object-oriented interface
* @export
* @class APIKeyApi
* @extends {BaseAPI}
*/
export class APIKeyApi extends BaseAPI {
/**
*
* @param {APIKeyCreateDto} aPIKeyCreateDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof APIKeyApi
*/
public createKey(aPIKeyCreateDto: APIKeyCreateDto, options?: AxiosRequestConfig) {
return APIKeyApiFp(this.configuration).createKey(aPIKeyCreateDto, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {number} id
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof APIKeyApi
*/
public deleteKey(id: number, options?: AxiosRequestConfig) {
return APIKeyApiFp(this.configuration).deleteKey(id, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {number} id
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof APIKeyApi
*/
public getKey(id: number, options?: AxiosRequestConfig) {
return APIKeyApiFp(this.configuration).getKey(id, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof APIKeyApi
*/
public getKeys(options?: AxiosRequestConfig) {
return APIKeyApiFp(this.configuration).getKeys(options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {number} id
* @param {APIKeyUpdateDto} aPIKeyUpdateDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof APIKeyApi
*/
public updateKey(id: number, aPIKeyUpdateDto: APIKeyUpdateDto, options?: AxiosRequestConfig) {
return APIKeyApiFp(this.configuration).updateKey(id, aPIKeyUpdateDto, options).then((request) => request(this.axios, this.basePath));
}
}
/**
* AlbumApi - axios parameter creator
* @export

View File

@ -0,0 +1,57 @@
<script lang="ts">
import { APIKeyResponseDto } from '@api';
import { createEventDispatcher } from 'svelte';
import KeyVariant from 'svelte-material-icons/KeyVariant.svelte';
import FullScreenModal from '../shared-components/full-screen-modal.svelte';
export let apiKey: Partial<APIKeyResponseDto>;
export let title = 'API Key';
export let cancelText = 'Cancel';
export let submitText = 'Save';
const dispatch = createEventDispatcher();
const handleCancel = () => dispatch('cancel');
const handleSubmit = () => dispatch('submit', { ...apiKey, name: apiKey.name });
</script>
<FullScreenModal on:clickOutside={() => handleCancel()}>
<div
class="border bg-immich-bg dark:bg-immich-dark-gray dark:border-immich-dark-gray p-4 shadow-sm w-[500px] rounded-3xl py-8 dark:text-immich-dark-fg"
>
<div
class="flex flex-col place-items-center place-content-center gap-4 px-4 text-immich-primary dark:text-immich-dark-primary"
>
<KeyVariant size="4em" />
<h1 class="text-2xl text-immich-primary dark:text-immich-dark-primary font-medium">
{title}
</h1>
</div>
<form on:submit|preventDefault={() => handleSubmit()} autocomplete="off">
<div class="m-4 flex flex-col gap-2">
<label class="immich-form-label" for="email">Name</label>
<input
class="immich-form-input"
id="name"
name="name"
type="text"
bind:value={apiKey.name}
/>
</div>
<div class="flex w-full px-4 gap-4 mt-8">
<button
type="button"
on:click={() => handleCancel()}
class="flex-1 transition-colors bg-gray-500 dark:bg-gray-200 hover:bg-gray-500/75 dark:hover:bg-gray-200/80 px-6 py-3 text-white dark:text-immich-dark-gray rounded-full shadow-md font-medium"
>{cancelText}
</button>
<button
type="submit"
class="flex-1 transition-colors bg-immich-primary dark:bg-immich-dark-primary hover:bg-immich-primary/75 dark:hover:bg-immich-dark-primary/80 dark:text-immich-dark-gray px-6 py-3 text-white rounded-full shadow-md w-full font-medium"
>{submitText}</button
>
</div>
</form>
</div>
</FullScreenModal>

View File

@ -0,0 +1,69 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import KeyVariant from 'svelte-material-icons/KeyVariant.svelte';
import { handleError } from '../../utils/handle-error';
import FullScreenModal from '../shared-components/full-screen-modal.svelte';
import {
notificationController,
NotificationType
} from '../shared-components/notification/notification';
export let secret = '';
const dispatch = createEventDispatcher();
const handleDone = () => dispatch('done');
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(secret);
notificationController.show({
message: 'Copied to clipboard!',
type: NotificationType.Info
});
} catch (error) {
handleError(error, 'Unable to copy to clipboard');
}
};
</script>
<FullScreenModal>
<div
class="border bg-immich-bg dark:bg-immich-dark-gray dark:border-immich-dark-gray p-4 shadow-sm w-[500px] rounded-3xl py-8 dark:text-immich-dark-fg"
>
<div
class="flex flex-col place-items-center place-content-center gap-4 px-4 text-immich-primary dark:text-immich-dark-primary"
>
<KeyVariant size="4em" />
<h1 class="text-2xl text-immich-primary dark:text-immich-dark-primary font-medium">
API Key
</h1>
<p class="text-sm dark:text-immich-dark-fg">
This value will only be shown once. Please be sure to copy it before closing the window.
</p>
</div>
<div class="m-4 flex flex-col gap-2">
<!-- <label class="immich-form-label" for="email">API Key</label> -->
<textarea
class="immich-form-input"
id="secret"
name="secret"
readonly={true}
value={secret}
/>
</div>
<div class="flex w-full px-4 gap-4 mt-8">
<button
on:click={() => handleCopy()}
class="flex-1 transition-colors bg-immich-primary dark:bg-immich-dark-primary hover:bg-immich-primary/75 dark:hover:bg-immich-dark-primary/80 dark:text-immich-dark-gray px-6 py-3 text-white rounded-full shadow-md w-full font-medium"
>Copy to Clipboard</button
>
<button
on:click={() => handleDone()}
class="flex-1 transition-colors bg-immich-primary dark:bg-immich-dark-primary hover:bg-immich-primary/75 dark:hover:bg-immich-dark-primary/80 dark:text-immich-dark-gray px-6 py-3 text-white rounded-full shadow-md w-full font-medium"
>Done</button
>
</div>
</div>
</FullScreenModal>

View File

@ -0,0 +1,45 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import FullScreenModal from './full-screen-modal.svelte';
export let title = 'Confirm Delete';
export let prompt = 'Are you sure you want to delete this item?';
export let confirmText = 'Confirm';
export let cancelText = 'Cancel';
const dispatch = createEventDispatcher();
const handleCancel = () => dispatch('cancel');
const handleConfirm = () => dispatch('confirm');
</script>
<FullScreenModal on:clickOutside={() => handleCancel()}>
<div
class="border bg-immich-bg dark:bg-immich-dark-gray dark:border-immich-dark-gray p-4 shadow-sm w-[500px] rounded-3xl py-8 dark:text-immich-dark-fg"
>
<div
class="flex flex-col place-items-center place-content-center gap-4 px-4 text-immich-primary dark:text-immich-dark-primary"
>
<h1 class="text-2xl text-immich-primary dark:text-immich-dark-primary font-medium">
{title}
</h1>
</div>
<div>
<p class="ml-4 text-md py-5 text-center">{prompt}</p>
<div class="flex w-full px-4 gap-4 mt-4">
<button
on:click={() => handleCancel()}
class="flex-1 transition-colors bg-immich-primary dark:bg-immich-dark-primary hover:bg-immich-primary/75 dark:hover:bg-immich-dark-primary/80 dark:text-immich-dark-gray px-6 py-3 text-white rounded-full shadow-md w-full font-medium"
>
{cancelText}
</button>
<button
on:click={() => handleConfirm()}
class="flex-1 transition-colors bg-red-500 hover:bg-red-400 px-6 py-3 text-white rounded-full w-full font-medium"
>
{confirmText}
</button>
</div>
</div>
</div>
</FullScreenModal>

View File

@ -0,0 +1,180 @@
<script lang="ts">
import { api, APIKeyResponseDto } from '@api';
import { onMount } from 'svelte';
import PencilOutline from 'svelte-material-icons/PencilOutline.svelte';
import TrashCanOutline from 'svelte-material-icons/TrashCanOutline.svelte';
import { fade } from 'svelte/transition';
import { handleError } from '../../utils/handle-error';
import APIKeyForm from '../forms/api-key-form.svelte';
import APIKeySecret from '../forms/api-key-secret.svelte';
import DeleteConfirmDialogue from '../shared-components/delete-confirm-dialogue.svelte';
import {
notificationController,
NotificationType
} from '../shared-components/notification/notification';
let keys: APIKeyResponseDto[] = [];
let newKey: Partial<APIKeyResponseDto> | null = null;
let editKey: APIKeyResponseDto | null = null;
let deleteKey: APIKeyResponseDto | null = null;
let secret = '';
const locale = navigator.language;
const format: Intl.DateTimeFormatOptions = {
month: 'short',
day: 'numeric',
year: 'numeric'
};
onMount(() => {
refreshKeys();
});
async function refreshKeys() {
const { data } = await api.keyApi.getKeys();
keys = data;
}
const handleCreate = async (event: CustomEvent<APIKeyResponseDto>) => {
try {
const dto = event.detail;
const { data } = await api.keyApi.createKey(dto);
secret = data.secret;
} catch (error) {
handleError(error, 'Unable to create a new API Key');
} finally {
await refreshKeys();
newKey = null;
}
};
const handleUpdate = async (event: CustomEvent<APIKeyResponseDto>) => {
if (!editKey) {
return;
}
const dto = event.detail;
try {
await api.keyApi.updateKey(editKey.id, { name: dto.name });
notificationController.show({
message: `Saved API Key`,
type: NotificationType.Info
});
} catch (error) {
handleError(error, 'Unable to save API Key');
} finally {
await refreshKeys();
editKey = null;
}
};
const handleDelete = async () => {
if (!deleteKey) {
return;
}
try {
await api.keyApi.deleteKey(deleteKey.id);
notificationController.show({
message: `Removed API Key: ${deleteKey.name}`,
type: NotificationType.Info
});
} catch (error) {
handleError(error, 'Unable to remove API Key');
} finally {
await refreshKeys();
deleteKey = null;
}
};
</script>
{#if newKey}
<APIKeyForm
title="New API Key"
submitText="Create"
apiKey={newKey}
on:submit={handleCreate}
on:cancel={() => (newKey = null)}
/>
{/if}
{#if secret}
<APIKeySecret {secret} on:done={() => (secret = '')} />
{/if}
{#if editKey}
<APIKeyForm
submitText="Save"
apiKey={editKey}
on:submit={handleUpdate}
on:cancel={() => (editKey = null)}
/>
{/if}
{#if deleteKey}
<DeleteConfirmDialogue
prompt="Are you sure you want to delete this API Key?"
on:confirm={() => handleDelete()}
on:cancel={() => (deleteKey = null)}
/>
{/if}
<section class="my-4">
<div class="flex flex-col gap-2" in:fade={{ duration: 500 }}>
<div class="flex justify-end mb-2">
<button
on:click={() => (newKey = { name: 'API Key' })}
class="text-sm bg-immich-primary dark:bg-immich-dark-primary hover:bg-immich-primary/75 dark:hover:bg-immich-dark-primary/80 px-4 py-2 text-white dark:text-immich-dark-gray rounded-full shadow-md font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>New API Key
</button>
</div>
{#if keys.length > 0}
<table class="text-left w-full">
<thead
class="border rounded-md mb-4 bg-gray-50 flex text-immich-primary w-full h-12 dark:bg-immich-dark-gray dark:text-immich-dark-primary dark:border-immich-dark-gray"
>
<tr class="flex w-full place-items-center">
<th class="text-center w-1/3 font-medium text-sm">Name</th>
<th class="text-center w-1/3 font-medium text-sm">Created</th>
<th class="text-center w-1/3 font-medium text-sm">Action</th>
</tr>
</thead>
<tbody class="overflow-y-auto rounded-md w-full block border dark:border-immich-dark-gray">
{#each keys as key, i}
{#key key.id}
<tr
class={`text-center flex place-items-center w-full h-[80px] dark:text-immich-dark-fg ${
i % 2 == 0
? 'bg-immich-gray dark:bg-immich-dark-gray/75'
: 'bg-immich-bg dark:bg-immich-dark-gray/50'
}`}
>
<td class="text-sm px-4 w-1/3 text-ellipsis">{key.name}</td>
<td class="text-sm px-4 w-1/3 text-ellipsis"
>{new Date(key.createdAt).toLocaleDateString(locale, format)}
</td>
<td class="text-sm px-4 w-1/3 text-ellipsis">
<button
on:click={() => (editKey = key)}
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"
>
<PencilOutline size="16" />
</button>
<button
on:click={() => (deleteKey = key)}
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"
>
<TrashCanOutline size="16" />
</button>
</td>
</tr>
{/key}
{/each}
</tbody>
</table>
{/if}
</div>
</section>

View File

@ -5,6 +5,7 @@
import SettingAccordion from '../admin-page/settings/setting-accordion.svelte';
import ChangePasswordSettings from './change-password-settings.svelte';
import OAuthSettings from './oauth-settings.svelte';
import UserAPIKeyList from './user-api-key-list.svelte';
import UserProfileSettings from './user-profile-settings.svelte';
export let user: UserResponseDto;
@ -32,6 +33,10 @@
<ChangePasswordSettings />
</SettingAccordion>
<SettingAccordion title="API Keys" subtitle="View and manage your API keys">
<UserAPIKeyList />
</SettingAccordion>
{#if oauthEnabled}
<SettingAccordion
title="OAuth"