Add web interface with admin functionality (#167)
							
								
								
									
										79
									
								
								.github/workflows/build_push_docker_latest.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						| @@ -8,7 +8,6 @@ on: | ||||
|     branches: [main] | ||||
|  | ||||
| jobs: | ||||
|  | ||||
|   build_and_push_server_latest: | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
| @@ -37,28 +36,56 @@ jobs: | ||||
|             altran1502/immich-server:latest | ||||
|  | ||||
|   build_and_push_microservice_latest: | ||||
|       runs-on: ubuntu-latest | ||||
|       steps: | ||||
|         - name: Checkout | ||||
|           uses: actions/checkout@v3 | ||||
|           with: | ||||
|             ref: "main" # branch | ||||
|         - name: Set up QEMU | ||||
|           uses: docker/setup-qemu-action@v2.0.0 | ||||
|         - name: Set up Docker Buildx | ||||
|           id: buildx | ||||
|           uses: docker/setup-buildx-action@v2.0.0 | ||||
|         - name: Login to Docker Hub | ||||
|           uses: docker/login-action@v2 | ||||
|           with: | ||||
|             username: ${{ secrets.DOCKERHUB_USERNAME }} | ||||
|             password: ${{ secrets.DOCKERHUB_TOKEN }} | ||||
|         - name: Build and Push Microservices | ||||
|           uses: docker/build-push-action@v3.0.0 | ||||
|           with: | ||||
|             context: ./microservices | ||||
|             file: ./microservices/Dockerfile | ||||
|             platforms: linux/arm/v7,linux/amd64 | ||||
|             push: ${{ github.event_name != 'pull_request' }} | ||||
|             tags: | | ||||
|               altran1502/immich-microservices:latest | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - name: Checkout | ||||
|         uses: actions/checkout@v3 | ||||
|         with: | ||||
|           ref: "main" # branch | ||||
|       - name: Set up QEMU | ||||
|         uses: docker/setup-qemu-action@v2.0.0 | ||||
|       - name: Set up Docker Buildx | ||||
|         id: buildx | ||||
|         uses: docker/setup-buildx-action@v2.0.0 | ||||
|       - name: Login to Docker Hub | ||||
|         uses: docker/login-action@v2 | ||||
|         with: | ||||
|           username: ${{ secrets.DOCKERHUB_USERNAME }} | ||||
|           password: ${{ secrets.DOCKERHUB_TOKEN }} | ||||
|       - name: Build and Push Microservices | ||||
|         uses: docker/build-push-action@v3.0.0 | ||||
|         with: | ||||
|           context: ./microservices | ||||
|           file: ./microservices/Dockerfile | ||||
|           platforms: linux/arm/v7,linux/amd64 | ||||
|           push: ${{ github.event_name != 'pull_request' }} | ||||
|           tags: | | ||||
|             altran1502/immich-microservices:latest | ||||
|  | ||||
|   build_and_push_web_latest: | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - name: Checkout | ||||
|         uses: actions/checkout@v3 | ||||
|         with: | ||||
|           ref: "main" # branch | ||||
|       - name: Set up QEMU | ||||
|         uses: docker/setup-qemu-action@v2.0.0 | ||||
|       - name: Set up Docker Buildx | ||||
|         id: buildx | ||||
|         uses: docker/setup-buildx-action@v2.0.0 | ||||
|       - name: Login to Docker Hub | ||||
|         uses: docker/login-action@v2 | ||||
|         with: | ||||
|           username: ${{ secrets.DOCKERHUB_USERNAME }} | ||||
|           password: ${{ secrets.DOCKERHUB_TOKEN }} | ||||
|       - name: Build and Push Web | ||||
|         uses: docker/build-push-action@v3.0.0 | ||||
|         with: | ||||
|           context: ./web | ||||
|           file: ./web/Dockerfile | ||||
|           platforms: linux/arm/v7,linux/amd64,linux/arm64 | ||||
|           target: prod | ||||
|           push: ${{ github.event_name != 'pull_request' }} | ||||
|           tags: | | ||||
|             altran1502/immich-web:latest | ||||
|   | ||||
							
								
								
									
										55
									
								
								.github/workflows/build_push_server_release.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						| @@ -12,14 +12,14 @@ jobs: | ||||
|       - name: Checkout | ||||
|         uses: actions/checkout@v3 | ||||
|         with: | ||||
|           ref: "main"  | ||||
|           ref: "main" | ||||
|           fetch-depth: 0 | ||||
|      | ||||
|       - name: 'Get Previous tag' | ||||
|  | ||||
|       - name: "Get Previous tag" | ||||
|         id: previoustag | ||||
|         uses: "WyriHaximus/github-action-get-previous-tag@v1" | ||||
|         with: | ||||
|             fallback: latest  | ||||
|           fallback: latest | ||||
|  | ||||
|       - name: Set up QEMU | ||||
|         uses: docker/setup-qemu-action@v2.0.0 | ||||
| @@ -50,14 +50,14 @@ jobs: | ||||
|       - name: Checkout | ||||
|         uses: actions/checkout@v3 | ||||
|         with: | ||||
|           ref: "main"  | ||||
|           ref: "main" | ||||
|           fetch-depth: 0 | ||||
|  | ||||
|       - name: 'Get Previous tag' | ||||
|       - name: "Get Previous tag" | ||||
|         id: previoustag | ||||
|         uses: "WyriHaximus/github-action-get-previous-tag@v1" | ||||
|         with: | ||||
|             fallback: latest  | ||||
|           fallback: latest | ||||
|  | ||||
|       - name: Set up QEMU | ||||
|         uses: docker/setup-qemu-action@v2.0.0 | ||||
| @@ -80,4 +80,43 @@ jobs: | ||||
|           platforms: linux/arm/v7,linux/amd64 | ||||
|           push: ${{ github.event_name != 'pull_request' }} | ||||
|           tags: | | ||||
|               altran1502/immich-microservices:${{ steps.previoustag.outputs.tag }} | ||||
|             altran1502/immich-microservices:${{ steps.previoustag.outputs.tag }} | ||||
|  | ||||
|   build_and_push_web_release: | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - name: Checkout | ||||
|         uses: actions/checkout@v3 | ||||
|         with: | ||||
|           ref: "main" | ||||
|           fetch-depth: 0 | ||||
|  | ||||
|       - name: "Get Previous tag" | ||||
|         id: previoustag | ||||
|         uses: "WyriHaximus/github-action-get-previous-tag@v1" | ||||
|         with: | ||||
|           fallback: latest | ||||
|  | ||||
|       - name: Set up QEMU | ||||
|         uses: docker/setup-qemu-action@v2.0.0 | ||||
|  | ||||
|       - name: Set up Docker Buildx | ||||
|         id: buildx | ||||
|         uses: docker/setup-buildx-action@v2.0.0 | ||||
|  | ||||
|       - name: Login to Docker Hub | ||||
|         uses: docker/login-action@v2 | ||||
|         with: | ||||
|           username: ${{ secrets.DOCKERHUB_USERNAME }} | ||||
|           password: ${{ secrets.DOCKERHUB_TOKEN }} | ||||
|  | ||||
|       - name: Build and push immich-web release | ||||
|         uses: docker/build-push-action@v3.0.0 | ||||
|         with: | ||||
|           context: ./web | ||||
|           file: ./web/Dockerfile | ||||
|           platforms: linux/arm/v7,linux/amd64,linux/arm64 | ||||
|           push: ${{ github.event_name != 'pull_request' }} | ||||
|           target: prod | ||||
|           tags: | | ||||
|             altran1502/immich-web:${{ steps.previoustag.outputs.tag }} | ||||
|   | ||||
							
								
								
									
										9
									
								
								NOTES.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,9 @@ | ||||
| # TODO | ||||
|  | ||||
| Server scenario with web | ||||
|  | ||||
| [ ] 1 user exist without admin right -> make admin on first check | ||||
|  | ||||
| [ ] 2 users exist without admin right -> ask user to choose which account will be the admin | ||||
|  | ||||
| [ X ] No users exist -> prompt signup form for Admin | ||||
							
								
								
									
										29
									
								
								README.md
									
									
									
									
									
								
							
							
						
						| @@ -86,10 +86,11 @@ I haven't tested with `Docker for Windows` as well as `WSL` on Windows | ||||
| You can use docker compose for development and testing out the application, there are several services that compose Immich: | ||||
|  | ||||
| 1. **NestJs** - Backend of the application | ||||
| 2. **PostgreSQL** - Main database of the application | ||||
| 3. **Redis** - For sharing websocket instance between docker instances and background tasks message queue. | ||||
| 4. **Nginx** - Load balancing and optimized file uploading. | ||||
| 5. **TensorFlow** - Object Detection and Image Classification. | ||||
| 2. **SvelteKit** - Web frontend of the application | ||||
| 3. **PostgreSQL** - Main database of the application | ||||
| 4. **Redis** - For sharing websocket instance between docker instances and background tasks message queue. | ||||
| 5. **Nginx** - Load balancing and optimized file uploading. | ||||
| 6. **TensorFlow** - Object Detection and Image Classification. | ||||
|  | ||||
| ## Step 1: Populate .env file | ||||
|  | ||||
| @@ -133,7 +134,7 @@ To start, run | ||||
| docker-compose -f ./docker/docker-compose.yml up  | ||||
| ``` | ||||
|  | ||||
| If you have a few thousand photos/videos, I suggest running docker-compose with scaling option for the `immich_server` container to handle high I/O load when using fast scrolling. | ||||
| If you have a few thousand photos/videos, I suggest running docker-compose with scaling option for the `immich_-erver` container to handle high I/O load when using fast scrolling. | ||||
|  | ||||
| ```bash | ||||
| docker-compose -f ./docker/docker-compose.yml up --scale immich-server=5  | ||||
| @@ -144,17 +145,17 @@ The server will be running at `http://your-ip:2283` through `Nginx` | ||||
|  | ||||
| ## Step 3: Register User | ||||
|  | ||||
| Use the command below on your terminal to create user as we don't have user interface for this function yet. | ||||
| Access the web interface at `http://your-ip:2285` to register an admin account. | ||||
|  | ||||
| ```bash | ||||
| curl --location --request POST 'http://your-server-ip:2283/auth/signUp' \ | ||||
| --header 'Content-Type: application/json' \ | ||||
| --data-raw '{ | ||||
|     "email": "testuser@email.com", | ||||
|     "password": "password" | ||||
| }' | ||||
| ``` | ||||
| <p align="left"> | ||||
|   <img src="design/admin-registration-form.png" width="300" title="Admin Registration"> | ||||
| <p/> | ||||
|  | ||||
| Additional accounts on the server can be created by the admin account. | ||||
|  | ||||
| <p align="left"> | ||||
|   <img src="design/admin-interface.png" width="500" title="Admin User Management"> | ||||
| <p/> | ||||
| ## Step 4: Run mobile app | ||||
|  | ||||
| The app is distributed on several platforms below. | ||||
|   | ||||
							
								
								
									
										
											BIN
										
									
								
								design/admin-interface.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 58 KiB | 
							
								
								
									
										
											BIN
										
									
								
								design/admin-registration-form.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 105 KiB | 
| @@ -1,15 +1,51 @@ | ||||
| ################################################################################### | ||||
| # Database | ||||
| ################################################################################### | ||||
|  | ||||
| DB_USERNAME=postgres | ||||
| DB_PASSWORD=postgres | ||||
| DB_DATABASE_NAME=immich | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
| ################################################################################### | ||||
| # Upload File Config | ||||
| ################################################################################### | ||||
|  | ||||
| UPLOAD_LOCATION=absolute_location_on_your_machine_where_you_want_to_store_the_backup | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
| ################################################################################### | ||||
| # JWT SECRET | ||||
| ################################################################################### | ||||
|  | ||||
| JWT_SECRET=randomstringthatissolongandpowerfulthatnoonecanguess | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
| ################################################################################### | ||||
| # MAPBOX | ||||
| ## ENABLE_MAPBOX is either true of false -> if true, you have to provide MAPBOX_KEY | ||||
| #################################################################################### | ||||
|  | ||||
| # ENABLE_MAPBOX is either true of false -> if true, you have to provide MAPBOX_KEY | ||||
| ENABLE_MAPBOX=false | ||||
| MAPBOX_KEY= | ||||
| MAPBOX_KEY= | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
| ################################################################################### | ||||
| # WEB | ||||
| ################################################################################### | ||||
|  | ||||
| # This is the URL of your vm/server where you host Immich, so that the web frontend | ||||
| # know where can it make the request to. | ||||
| # For example: If your server IP address is 10.1.11.50, the environment variable will | ||||
| # be VITE_SERVER_ENDPOINT=http://10.1.11.50:2283 | ||||
|  | ||||
| VITE_SERVER_ENDPOINT= | ||||
| @@ -44,6 +44,24 @@ services: | ||||
|     networks: | ||||
|       - immich-network | ||||
|  | ||||
|   immich-web: | ||||
|     image: immich-web-dev:1.9.0 | ||||
|     build: | ||||
|       context: ../web | ||||
|       dockerfile: Dockerfile | ||||
|       target: dev | ||||
|     command: npm run dev --host | ||||
|     env_file: | ||||
|       - .env | ||||
|     ports: | ||||
|       - 3002:3002 | ||||
|       - 24678:24678 | ||||
|     volumes: | ||||
|       - ../web:/usr/src/app | ||||
|       - /usr/src/app/node_modules | ||||
|     networks: | ||||
|       - immich-network | ||||
|     restart: always | ||||
|  | ||||
|   redis: | ||||
|     container_name: immich_redis | ||||
|   | ||||
| @@ -17,7 +17,7 @@ services: | ||||
|       - database | ||||
|     networks: | ||||
|       - immich-network | ||||
|     restart: unless-stopped | ||||
|     restart: always | ||||
|  | ||||
|   immich-microservices: | ||||
|     image: altran1502/immich-microservices:latest | ||||
| @@ -34,13 +34,27 @@ services: | ||||
|       - database | ||||
|     networks: | ||||
|       - immich-network | ||||
|     restart: unless-stopped | ||||
|     restart: always | ||||
|  | ||||
|   immich-web: | ||||
|     image: altran1502/immich-web:latest | ||||
|     entrypoint: ["/bin/sh", "./entrypoint.sh"] | ||||
|     env_file: | ||||
|       - .env | ||||
|     ports: | ||||
|       - 2285:3000 | ||||
|     networks: | ||||
|       - immich-network | ||||
|     restart: always | ||||
|  | ||||
|  | ||||
|   redis: | ||||
|     container_name: immich_redis | ||||
|     image: redis:6.2 | ||||
|     networks: | ||||
|       - immich-network | ||||
|     restart: always | ||||
|      | ||||
|  | ||||
|   database: | ||||
|     container_name: immich_postgres | ||||
| @@ -73,6 +87,7 @@ services: | ||||
|       - immich-network | ||||
|     depends_on: | ||||
|       - immich-server | ||||
|     restart: always | ||||
|  | ||||
| networks: | ||||
|   immich-network: | ||||
|   | ||||
| @@ -1,5 +1,6 @@ | ||||
| { | ||||
|   "singleQuote": true, | ||||
|   "trailingComma": "all", | ||||
|   "printWidth": 120 | ||||
|   "printWidth": 120, | ||||
|   "semi": true | ||||
| } | ||||
|   | ||||
| @@ -7,16 +7,16 @@ import { SignUpDto } from './dto/sign-up.dto'; | ||||
|  | ||||
| @Controller('auth') | ||||
| export class AuthController { | ||||
|   constructor(private readonly authService: AuthService) {} | ||||
|   constructor(private readonly authService: AuthService) { } | ||||
|  | ||||
|   @Post('/login') | ||||
|   async login(@Body(ValidationPipe) loginCredential: LoginCredentialDto) { | ||||
|     return await this.authService.login(loginCredential); | ||||
|   } | ||||
|  | ||||
|   @Post('/signUp') | ||||
|   async signUp(@Body(ValidationPipe) signUpCrendential: SignUpDto) { | ||||
|     return await this.authService.signUp(signUpCrendential); | ||||
|   @Post('/admin-sign-up') | ||||
|   async adminSignUp(@Body(ValidationPipe) signUpCrendential: SignUpDto) { | ||||
|     return await this.authService.adminSignUp(signUpCrendential); | ||||
|   } | ||||
|  | ||||
|   @UseGuards(JwtAuthGuard) | ||||
|   | ||||
| @@ -14,12 +14,12 @@ export class AuthService { | ||||
|     @InjectRepository(UserEntity) | ||||
|     private userRepository: Repository<UserEntity>, | ||||
|     private immichJwtService: ImmichJwtService, | ||||
|   ) {} | ||||
|   ) { } | ||||
|  | ||||
|   private async validateUser(loginCredential: LoginCredentialDto): Promise<UserEntity> { | ||||
|     const user = await this.userRepository.findOne( | ||||
|       { email: loginCredential.email }, | ||||
|       { select: ['id', 'email', 'password', 'salt'] }, | ||||
|       { select: ['id', 'email', 'password', 'salt', 'firstName', 'lastName', 'isAdmin'] }, | ||||
|     ); | ||||
|  | ||||
|     const isAuthenticated = await this.validatePassword(user.password, loginCredential.password, user.salt); | ||||
| @@ -44,32 +44,45 @@ export class AuthService { | ||||
|       accessToken: await this.immichJwtService.generateToken(payload), | ||||
|       userId: validatedUser.id, | ||||
|       userEmail: validatedUser.email, | ||||
|       firstName: validatedUser.firstName, | ||||
|       lastName: validatedUser.lastName, | ||||
|       isAdmin: validatedUser.isAdmin, | ||||
|       profileImagePath: validatedUser.profileImagePath, | ||||
|       isFirstLogin: validatedUser.isFirstLoggedIn | ||||
|     }; | ||||
|   } | ||||
|  | ||||
|   public async signUp(signUpCrendential: SignUpDto) { | ||||
|     const registerUser = await this.userRepository.findOne({ email: signUpCrendential.email }); | ||||
|  | ||||
|     if (registerUser) { | ||||
|       throw new BadRequestException('User exist'); | ||||
|   public async adminSignUp(signUpCrendential: SignUpDto) { | ||||
|     const adminUser = await this.userRepository.findOne({ where: { isAdmin: true } }); | ||||
|  | ||||
|     if (adminUser) { | ||||
|       throw new BadRequestException('The server already has an admin') | ||||
|     } | ||||
|  | ||||
|     const newUser = new UserEntity(); | ||||
|     newUser.email = signUpCrendential.email; | ||||
|     newUser.salt = await bcrypt.genSalt(); | ||||
|     newUser.password = await this.hashPassword(signUpCrendential.password, newUser.salt); | ||||
|  | ||||
|     const newAdminUser = new UserEntity(); | ||||
|     newAdminUser.email = signUpCrendential.email; | ||||
|     newAdminUser.salt = await bcrypt.genSalt(); | ||||
|     newAdminUser.password = await this.hashPassword(signUpCrendential.password, newAdminUser.salt); | ||||
|     newAdminUser.firstName = signUpCrendential.firstName; | ||||
|     newAdminUser.lastName = signUpCrendential.lastName; | ||||
|     newAdminUser.isAdmin = true; | ||||
|  | ||||
|     try { | ||||
|       const savedUser = await this.userRepository.save(newUser); | ||||
|       const savedNewAdminUserUser = await this.userRepository.save(newAdminUser); | ||||
|  | ||||
|       return { | ||||
|         id: savedUser.id, | ||||
|         email: savedUser.email, | ||||
|         createdAt: savedUser.createdAt, | ||||
|         id: savedNewAdminUserUser.id, | ||||
|         email: savedNewAdminUserUser.email, | ||||
|         firstName: savedNewAdminUserUser.firstName, | ||||
|         lastName: savedNewAdminUserUser.lastName, | ||||
|         createdAt: savedNewAdminUserUser.createdAt, | ||||
|       }; | ||||
|  | ||||
|     } catch (e) { | ||||
|       Logger.error('e', 'signUp'); | ||||
|       throw new InternalServerErrorException('Failed to register new user'); | ||||
|       throw new InternalServerErrorException('Failed to register new admin user'); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   | ||||
| @@ -6,4 +6,10 @@ export class SignUpDto { | ||||
|  | ||||
|   @IsNotEmpty() | ||||
|   password: string; | ||||
|  | ||||
|   @IsNotEmpty() | ||||
|   firstName: string; | ||||
|  | ||||
|   @IsNotEmpty() | ||||
|   lastName: string; | ||||
| } | ||||
|   | ||||
| @@ -1 +1,27 @@ | ||||
| export class CreateUserDto {} | ||||
| import { IsNotEmpty, IsOptional } from 'class-validator'; | ||||
|  | ||||
| export class CreateUserDto { | ||||
|   @IsNotEmpty() | ||||
|   email: string; | ||||
|  | ||||
|   @IsNotEmpty() | ||||
|   password: string; | ||||
|  | ||||
|   @IsNotEmpty() | ||||
|   firstName: string; | ||||
|  | ||||
|   @IsNotEmpty() | ||||
|   lastName: string; | ||||
|  | ||||
|   @IsOptional() | ||||
|   profileImagePath: string; | ||||
|  | ||||
|   @IsOptional() | ||||
|   isAdmin: boolean; | ||||
|  | ||||
|   @IsOptional() | ||||
|   isFirstLoggedIn: boolean; | ||||
|  | ||||
|   @IsOptional() | ||||
|   id: string; | ||||
| } | ||||
|   | ||||
| @@ -5,6 +5,15 @@ export class UserEntity { | ||||
|   @PrimaryGeneratedColumn('uuid') | ||||
|   id: string; | ||||
|  | ||||
|   @Column() | ||||
|   firstName: string; | ||||
|  | ||||
|   @Column() | ||||
|   lastName: string; | ||||
|  | ||||
|   @Column() | ||||
|   isAdmin: boolean; | ||||
|  | ||||
|   @Column() | ||||
|   email: string; | ||||
|  | ||||
| @@ -14,6 +23,12 @@ export class UserEntity { | ||||
|   @Column({ select: false }) | ||||
|   salt: string; | ||||
|  | ||||
|   @Column() | ||||
|   profileImagePath: string; | ||||
|  | ||||
|   @Column() | ||||
|   isFirstLoggedIn: boolean; | ||||
|  | ||||
|   @CreateDateColumn() | ||||
|   createdAt: string; | ||||
| } | ||||
|   | ||||
| @@ -1,15 +1,38 @@ | ||||
| import { Controller, Get, Post, Body, Patch, Param, Delete, UseGuards } from '@nestjs/common'; | ||||
| import { Controller, Get, Post, Body, Patch, Param, Delete, UseGuards, ValidationPipe, Put, Query } from '@nestjs/common'; | ||||
| import { UserService } from './user.service'; | ||||
| import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard'; | ||||
| import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator'; | ||||
| import { CreateUserDto } from './dto/create-user.dto'; | ||||
| import { AdminRolesGuard } from '../../middlewares/admin-role-guard.middleware'; | ||||
| import { UpdateUserDto } from './dto/update-user.dto'; | ||||
| import { boolean } from 'joi'; | ||||
|  | ||||
| @UseGuards(JwtAuthGuard) | ||||
| @Controller('user') | ||||
| export class UserController { | ||||
|   constructor(private readonly userService: UserService) {} | ||||
|   constructor(private readonly userService: UserService) { } | ||||
|  | ||||
|   @UseGuards(JwtAuthGuard) | ||||
|   @Get() | ||||
|   async getAllUsers(@GetAuthUser() authUser: AuthUserDto) { | ||||
|     return await this.userService.getAllUsers(authUser); | ||||
|   async getAllUsers(@GetAuthUser() authUser: AuthUserDto, @Query('isAll') isAll: boolean) { | ||||
|     return await this.userService.getAllUsers(authUser, isAll); | ||||
|   } | ||||
|  | ||||
|   @UseGuards(JwtAuthGuard) | ||||
|   @UseGuards(AdminRolesGuard) | ||||
|   @Post() | ||||
|   async createNewUser(@Body(ValidationPipe) createUserDto: CreateUserDto) { | ||||
|     return await this.userService.createUser(createUserDto); | ||||
|   } | ||||
|  | ||||
|   @Get('/count') | ||||
|   async getUserCount(@Query('isAdmin') isAdmin: boolean) { | ||||
|  | ||||
|     return await this.userService.getUserCount(isAdmin); | ||||
|   } | ||||
|  | ||||
|   @UseGuards(JwtAuthGuard) | ||||
|   @Put() | ||||
|   async updateUser(@Body(ValidationPipe) updateUserDto: UpdateUserDto) { | ||||
|     return await this.userService.updateUser(updateUserDto) | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -3,10 +3,14 @@ import { UserService } from './user.service'; | ||||
| import { UserController } from './user.controller'; | ||||
| import { TypeOrmModule } from '@nestjs/typeorm'; | ||||
| import { UserEntity } from './entities/user.entity'; | ||||
| import { ImmichJwtModule } from '../../modules/immich-jwt/immich-jwt.module'; | ||||
| import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service'; | ||||
| import { JwtModule } from '@nestjs/jwt'; | ||||
| import { jwtConfig } from '../../config/jwt.config'; | ||||
|  | ||||
| @Module({ | ||||
|   imports: [TypeOrmModule.forFeature([UserEntity])], | ||||
|   imports: [TypeOrmModule.forFeature([UserEntity]), ImmichJwtModule, JwtModule.register(jwtConfig)], | ||||
|   controllers: [UserController], | ||||
|   providers: [UserService], | ||||
|   providers: [UserService, ImmichJwtService], | ||||
| }) | ||||
| export class UserModule {} | ||||
| export class UserModule { } | ||||
|   | ||||
| @@ -1,21 +1,127 @@ | ||||
| import { Injectable } from '@nestjs/common'; | ||||
| import { BadRequestException, Injectable, InternalServerErrorException, Logger } from '@nestjs/common'; | ||||
| import { InjectRepository } from '@nestjs/typeorm'; | ||||
| import { Not, Repository } from 'typeorm'; | ||||
| import { AuthUserDto } from '../../decorators/auth-user.decorator'; | ||||
| import { CreateUserDto } from './dto/create-user.dto'; | ||||
| import { UpdateUserDto } from './dto/update-user.dto'; | ||||
| import { UserEntity } from './entities/user.entity'; | ||||
| import * as bcrypt from 'bcrypt'; | ||||
|  | ||||
|  | ||||
| @Injectable() | ||||
| export class UserService { | ||||
|   constructor( | ||||
|     @InjectRepository(UserEntity) | ||||
|     private userRepository: Repository<UserEntity>, | ||||
|   ) {} | ||||
|   ) { } | ||||
|  | ||||
|   async getAllUsers(authUser: AuthUserDto, isAll: boolean) { | ||||
|  | ||||
|     if (isAll) { | ||||
|       return await this.userRepository.find(); | ||||
|     } | ||||
|  | ||||
|   async getAllUsers(authUser: AuthUserDto) { | ||||
|     return await this.userRepository.find({ | ||||
|       where: { id: Not(authUser.id) }, | ||||
|       order: { | ||||
|         createdAt: 'DESC' | ||||
|       } | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   async getUserCount(isAdmin: boolean) { | ||||
|     let users; | ||||
|  | ||||
|     if (isAdmin) { | ||||
|       users = await this.userRepository.find({ where: { isAdmin: true } }); | ||||
|     } else { | ||||
|       users = await this.userRepository.find(); | ||||
|     } | ||||
|  | ||||
|  | ||||
|     return { | ||||
|       userCount: users.length | ||||
|     } | ||||
|  | ||||
|   } | ||||
|  | ||||
|   async createUser(createUserDto: CreateUserDto) { | ||||
|     const user = await this.userRepository.findOne({ where: { email: createUserDto.email } }); | ||||
|  | ||||
|     if (user) { | ||||
|       throw new BadRequestException('User exists'); | ||||
|     } | ||||
|  | ||||
|     const newUser = new UserEntity(); | ||||
|     newUser.email = createUserDto.email; | ||||
|     newUser.salt = await bcrypt.genSalt(); | ||||
|     newUser.password = await this.hashPassword(createUserDto.password, newUser.salt); | ||||
|     newUser.firstName = createUserDto.firstName; | ||||
|     newUser.lastName = createUserDto.lastName; | ||||
|     newUser.isAdmin = false; | ||||
|  | ||||
|  | ||||
|     try { | ||||
|       const savedUser = await this.userRepository.save(newUser); | ||||
|  | ||||
|       return { | ||||
|         id: savedUser.id, | ||||
|         email: savedUser.email, | ||||
|         firstName: savedUser.firstName, | ||||
|         lastName: savedUser.lastName, | ||||
|         createdAt: savedUser.createdAt, | ||||
|       }; | ||||
|  | ||||
|     } catch (e) { | ||||
|       Logger.error(e, 'Create new user'); | ||||
|       throw new InternalServerErrorException('Failed to register new user'); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   private async hashPassword(password: string, salt: string): Promise<string> { | ||||
|     return bcrypt.hash(password, salt); | ||||
|   } | ||||
|  | ||||
|  | ||||
|   async updateUser(updateUserDto: UpdateUserDto) { | ||||
|     const user = await this.userRepository.findOne(updateUserDto.id); | ||||
|  | ||||
|     user.lastName = updateUserDto.lastName || user.lastName; | ||||
|     user.firstName = updateUserDto.firstName || user.firstName; | ||||
|     user.profileImagePath = updateUserDto.profileImagePath || user.profileImagePath; | ||||
|     user.isFirstLoggedIn = updateUserDto.isFirstLoggedIn || user.isFirstLoggedIn; | ||||
|  | ||||
|     // If payload includes password - Create new password for user | ||||
|     if (updateUserDto.password) { | ||||
|       user.salt = await bcrypt.genSalt(); | ||||
|       user.password = await this.hashPassword(updateUserDto.password, user.salt); | ||||
|     } | ||||
|  | ||||
|     if (updateUserDto.isAdmin) { | ||||
|       const adminUser = await this.userRepository.findOne({ where: { isAdmin: true } }) | ||||
|  | ||||
|       if (adminUser) { | ||||
|         throw new BadRequestException("Admin user exists") | ||||
|       } | ||||
|  | ||||
|       user.isAdmin = true; | ||||
|     } | ||||
|  | ||||
|     try { | ||||
|       const updatedUser = await this.userRepository.save(user); | ||||
|  | ||||
|       return { | ||||
|         id: updatedUser.id, | ||||
|         email: updatedUser.email, | ||||
|         firstName: updatedUser.firstName, | ||||
|         lastName: updatedUser.lastName, | ||||
|         isAdmin: updatedUser.isAdmin, | ||||
|         profileImagePath: updatedUser.profileImagePath, | ||||
|       }; | ||||
|  | ||||
|     } catch (e) { | ||||
|       Logger.error(e, 'Create new user'); | ||||
|       throw new InternalServerErrorException('Failed to register new user'); | ||||
|     } | ||||
|   } | ||||
| } | ||||
|   | ||||
							
								
								
									
										15
									
								
								server/src/app.controller.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,15 @@ | ||||
| import { Controller, Get, Res, Headers } from '@nestjs/common'; | ||||
| import { Response } from 'express'; | ||||
|  | ||||
| @Controller() | ||||
|  | ||||
| export class AppController { | ||||
|   constructor() { } | ||||
|  | ||||
|   @Get() | ||||
|   async redirectToWebpage(@Res({ passthrough: true }) res: Response, @Headers() headers) { | ||||
|     const host = headers.host; | ||||
|  | ||||
|     return res.redirect(`http://${host}:2285`) | ||||
|   } | ||||
| } | ||||
| @@ -15,6 +15,7 @@ import { ServerInfoModule } from './api-v1/server-info/server-info.module'; | ||||
| import { BackgroundTaskModule } from './modules/background-task/background-task.module'; | ||||
| import { CommunicationModule } from './api-v1/communication/communication.module'; | ||||
| import { SharingModule } from './api-v1/sharing/sharing.module'; | ||||
| import { AppController } from './app.controller'; | ||||
|  | ||||
| @Module({ | ||||
|   imports: [ | ||||
| @@ -44,7 +45,7 @@ import { SharingModule } from './api-v1/sharing/sharing.module'; | ||||
|  | ||||
|     SharingModule, | ||||
|   ], | ||||
|   controllers: [], | ||||
|   controllers: [AppController], | ||||
|   providers: [], | ||||
| }) | ||||
| export class AppModule implements NestModule { | ||||
|   | ||||
| @@ -1,4 +1,4 @@ | ||||
| import { createParamDecorator, ExecutionContext } from '@nestjs/common'; | ||||
| import { createParamDecorator, ExecutionContext, UnauthorizedException } from '@nestjs/common'; | ||||
| import { UserEntity } from '../api-v1/user/entities/user.entity'; | ||||
| // import { AuthUserDto } from './dto/auth-user.dto'; | ||||
|  | ||||
| @@ -18,4 +18,4 @@ export const GetAuthUser = createParamDecorator((data, ctx: ExecutionContext): A | ||||
|   }; | ||||
|  | ||||
|   return authUser; | ||||
| }); | ||||
| }); | ||||
| @@ -7,6 +7,8 @@ import { RedisIoAdapter } from './middlewares/redis-io.adapter.middleware'; | ||||
| async function bootstrap() { | ||||
|   const app = await NestFactory.create<NestExpressApplication>(AppModule); | ||||
|  | ||||
|   app.enableCors(); | ||||
|  | ||||
|   app.set('trust proxy'); | ||||
|  | ||||
|   app.useWebSocketAdapter(new RedisIoAdapter(app)); | ||||
|   | ||||
							
								
								
									
										30
									
								
								server/src/middlewares/admin-role-guard.middleware.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,30 @@ | ||||
| import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; | ||||
| import { Reflector } from '@nestjs/core'; | ||||
| import { JwtService } from '@nestjs/jwt'; | ||||
| import { InjectRepository } from '@nestjs/typeorm'; | ||||
| import { Repository } from 'typeorm'; | ||||
| import { UserEntity } from '../api-v1/user/entities/user.entity'; | ||||
| import { ImmichJwtService } from '../modules/immich-jwt/immich-jwt.service'; | ||||
|  | ||||
| @Injectable() | ||||
| export class AdminRolesGuard implements CanActivate { | ||||
|   constructor(private reflector: Reflector, private jwtService: ImmichJwtService, | ||||
|     @InjectRepository(UserEntity) | ||||
|     private userRepository: Repository<UserEntity>, | ||||
|   ) { } | ||||
|  | ||||
|   async canActivate(context: ExecutionContext): Promise<boolean> { | ||||
|     const request = context.switchToHttp().getRequest(); | ||||
|  | ||||
|     if (request.headers['authorization']) { | ||||
|       const bearerToken = request.headers['authorization'].split(" ")[1] | ||||
|       const { userId } = await this.jwtService.validateToken(bearerToken); | ||||
|  | ||||
|       const user = await this.userRepository.findOne(userId); | ||||
|  | ||||
|       return user.isAdmin; | ||||
|     } | ||||
|  | ||||
|     return false; | ||||
|   } | ||||
| } | ||||
| @@ -0,0 +1,40 @@ | ||||
| import { MigrationInterface, QueryRunner } from "typeorm"; | ||||
|  | ||||
| export class UpdateUserTableWithAdminAndName1652633525943 implements MigrationInterface { | ||||
|  | ||||
|   public async up(queryRunner: QueryRunner): Promise<void> { | ||||
|     await queryRunner.query(` | ||||
|       alter table users | ||||
|           add column if not exists "firstName" varchar default ''; | ||||
|  | ||||
|       alter table users | ||||
|           add column if not exists "lastName" varchar default ''; | ||||
|  | ||||
|       alter table users | ||||
|           add column if not exists "profileImagePath" varchar default ''; | ||||
|  | ||||
|       alter table users | ||||
|           add column if not exists "isAdmin" bool default false; | ||||
|        | ||||
|       alter table users | ||||
|           add column if not exists "isFirstLoggedIn" bool default true; | ||||
|       `) | ||||
|  | ||||
|  | ||||
|   } | ||||
|  | ||||
|   public async down(queryRunner: QueryRunner): Promise<void> { | ||||
|     await queryRunner.query(` | ||||
|         alter table users | ||||
|           drop column "firstName"; | ||||
|  | ||||
|         alter table users | ||||
|           drop column "lastName"; | ||||
|  | ||||
|         alter table users | ||||
|           drop column "isAdmin"; | ||||
|          | ||||
|         `); | ||||
|   } | ||||
|  | ||||
| } | ||||
							
								
								
									
										4
									
								
								web/.dockerignore
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,4 @@ | ||||
| node_modules/ | ||||
| upload/ | ||||
| dist/ | ||||
|  | ||||
							
								
								
									
										20
									
								
								web/.eslintrc.cjs
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,20 @@ | ||||
| module.exports = { | ||||
| 	root: true, | ||||
| 	parser: '@typescript-eslint/parser', | ||||
| 	extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended', 'prettier'], | ||||
| 	plugins: ['svelte3', '@typescript-eslint'], | ||||
| 	ignorePatterns: ['*.cjs'], | ||||
| 	overrides: [{ files: ['*.svelte'], processor: 'svelte3/svelte3' }], | ||||
| 	settings: { | ||||
| 		'svelte3/typescript': () => require('typescript') | ||||
| 	}, | ||||
| 	parserOptions: { | ||||
| 		sourceType: 'module', | ||||
| 		ecmaVersion: 2020 | ||||
| 	}, | ||||
| 	env: { | ||||
| 		browser: true, | ||||
| 		es2017: true, | ||||
| 		node: true | ||||
| 	} | ||||
| }; | ||||
							
								
								
									
										10
									
								
								web/.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,10 @@ | ||||
| .DS_Store | ||||
| node_modules | ||||
| /build | ||||
| /.svelte-kit | ||||
| /package | ||||
| .env | ||||
| .env.* | ||||
| !.env.example | ||||
| .vercel | ||||
| .output | ||||
							
								
								
									
										1
									
								
								web/.npmrc
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| engine-strict=true | ||||
							
								
								
									
										7
									
								
								web/.prettierrc
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,7 @@ | ||||
| { | ||||
| 	"useTabs": true, | ||||
| 	"singleQuote": true, | ||||
| 	"trailingComma": "all", | ||||
| 	"printWidth": 120, | ||||
| 	"semi": true | ||||
| } | ||||
							
								
								
									
										8
									
								
								web/CHANGELOG.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,8 @@ | ||||
| # default-template | ||||
|  | ||||
| ## 0.0.2-next.0 | ||||
| ### Patch Changes | ||||
|  | ||||
|  | ||||
|  | ||||
| - [chore] upgrade cookie library ([#4592](https://github.com/sveltejs/kit/pull/4592)) | ||||
							
								
								
									
										35
									
								
								web/Dockerfile
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,35 @@ | ||||
| # Our Node base image | ||||
| FROM node:16-alpine3.14 as base | ||||
|  | ||||
| WORKDIR /usr/src/app | ||||
|  | ||||
| RUN chown node:node /usr/src/app | ||||
|  | ||||
| COPY --chown=node:node package*.json ./ | ||||
|  | ||||
| RUN npm install | ||||
|  | ||||
| COPY --chown=node:node . . | ||||
|  | ||||
| EXPOSE 3000 | ||||
| EXPOSE 24678 | ||||
|  | ||||
| FROM base AS dev | ||||
| ENV CHOKIDAR_USEPOLLING=true | ||||
| CMD ["npm", "run", "dev"] | ||||
|  | ||||
| FROM node:16-alpine3.14 as prod | ||||
|  | ||||
| WORKDIR /usr/src/app | ||||
|  | ||||
| RUN chown node:node /usr/src/app | ||||
|  | ||||
| COPY --chown=node:node package*.json ./ | ||||
| COPY --chown=node:node . . | ||||
|  | ||||
| RUN npm install | ||||
|  | ||||
| EXPOSE 3000 | ||||
|  | ||||
|  | ||||
| # Issue build command in entrypoint.sh to capture user .env file instead of the builder .env file. | ||||
							
								
								
									
										38
									
								
								web/README.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,38 @@ | ||||
| # create-svelte | ||||
|  | ||||
| Everything you need to build a Svelte project, powered by [`create-svelte`](https://github.com/sveltejs/kit/tree/master/packages/create-svelte). | ||||
|  | ||||
| ## Creating a project | ||||
|  | ||||
| If you're seeing this, you've probably already done this step. Congrats! | ||||
|  | ||||
| ```bash | ||||
| # create a new project in the current directory | ||||
| npm init svelte | ||||
|  | ||||
| # create a new project in my-app | ||||
| npm init svelte my-app | ||||
| ``` | ||||
|  | ||||
| ## Developing | ||||
|  | ||||
| Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server: | ||||
|  | ||||
| ```bash | ||||
| npm run dev | ||||
|  | ||||
| # or start the server and open the app in a new browser tab | ||||
| npm run dev -- --open | ||||
| ``` | ||||
|  | ||||
| ## Building | ||||
|  | ||||
| To create a production version of your app: | ||||
|  | ||||
| ```bash | ||||
| npm run build | ||||
| ``` | ||||
|  | ||||
| You can preview the production build with `npm run preview`. | ||||
|  | ||||
| > To deploy your app, you may need to install an [adapter](https://kit.svelte.dev/docs/adapters) for your target environment. | ||||
							
								
								
									
										1
									
								
								web/entrypoint.sh
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| npm run build && node /usr/src/app/build/index.js | ||||
							
								
								
									
										5783
									
								
								web/package-lock.json
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
							
								
								
									
										45
									
								
								web/package.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,45 @@ | ||||
| { | ||||
| 	"name": "web", | ||||
| 	"version": "0.0.1", | ||||
| 	"scripts": { | ||||
| 		"dev": "svelte-kit dev --host 0.0.0.0 --port 3002", | ||||
| 		"build": "svelte-kit build", | ||||
| 		"package": "svelte-kit package", | ||||
| 		"preview": "svelte-kit preview", | ||||
| 		"prepare": "svelte-kit sync", | ||||
| 		"check": "svelte-check --tsconfig ./tsconfig.json", | ||||
| 		"check:watch": "svelte-check --tsconfig ./tsconfig.json --watch", | ||||
| 		"lint": "prettier --ignore-path .gitignore --check --plugin-search-dir=. . && eslint --ignore-path .gitignore .", | ||||
| 		"format": "prettier --ignore-path .gitignore --write --plugin-search-dir=. ." | ||||
| 	}, | ||||
| 	"devDependencies": { | ||||
| 		"@sveltejs/adapter-auto": "next", | ||||
| 		"@sveltejs/adapter-node": "^1.0.0-next.73", | ||||
| 		"@sveltejs/kit": "next", | ||||
| 		"@types/bcrypt": "^5.0.0", | ||||
| 		"@types/cookie": "^0.4.1", | ||||
| 		"@typescript-eslint/eslint-plugin": "^5.10.1", | ||||
| 		"@typescript-eslint/parser": "^5.10.1", | ||||
| 		"autoprefixer": "^10.4.7", | ||||
| 		"eslint": "^8.12.0", | ||||
| 		"eslint-config-prettier": "^8.3.0", | ||||
| 		"eslint-plugin-svelte3": "^4.0.0", | ||||
| 		"postcss": "^8.4.13", | ||||
| 		"prettier": "^2.5.1", | ||||
| 		"prettier-plugin-svelte": "^2.5.0", | ||||
| 		"svelte": "^3.46.0", | ||||
| 		"svelte-check": "^2.2.6", | ||||
| 		"svelte-preprocess": "^4.10.1", | ||||
| 		"tailwindcss": "^3.0.24", | ||||
| 		"tslib": "^2.3.1", | ||||
| 		"typescript": "~4.6.2" | ||||
| 	}, | ||||
| 	"type": "module", | ||||
| 	"dependencies": { | ||||
| 		"@fontsource/fira-mono": "^4.5.0", | ||||
| 		"@lukeed/uuid": "^2.0.0", | ||||
| 		"bcrypt": "^5.0.1", | ||||
| 		"cookie": "^0.4.2", | ||||
| 		"svelte-material-icons": "^2.0.2" | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										6
									
								
								web/postcss.config.cjs
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,6 @@ | ||||
| module.exports = { | ||||
|   plugins: { | ||||
|     tailwindcss: {}, | ||||
|     autoprefixer: {}, | ||||
|   }, | ||||
| } | ||||
							
								
								
									
										31
									
								
								web/src/app.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,31 @@ | ||||
| @import url('https://fonts.googleapis.com/css2?family=Work+Sans:wght@300;400;500;600;700&display=swap'); | ||||
| @import url('https://fonts.googleapis.com/css2?family=Snowburst+One&display=swap'); | ||||
|  | ||||
| @tailwind base; | ||||
| @tailwind components; | ||||
| @tailwind utilities; | ||||
|  | ||||
| :root { | ||||
|   font-family: 'Work Sans', sans-serif; | ||||
| } | ||||
|  | ||||
| body { | ||||
| 	min-height: 100vh; | ||||
| 	margin: 0; | ||||
| 	background-color: #f6f8fe; | ||||
|   color: #5f6368; | ||||
| } | ||||
|  | ||||
| @layer utilities { | ||||
|   .immich-form-input { | ||||
|     @apply bg-slate-100 p-2 rounded-md focus:border-immich-primary text-sm | ||||
|   } | ||||
|  | ||||
|   .immich-form-label { | ||||
|     @apply font-medium text-sm text-gray-500 | ||||
|   } | ||||
|  | ||||
|   .immich-btn-primary { | ||||
|     @apply bg-immich-primary text-gray-100 border rounded-xl py-2 px-4 transition-all duration-150 hover:bg-immich-primary hover:shadow-lg text-sm font-medium | ||||
|   } | ||||
| } | ||||
							
								
								
									
										32
									
								
								web/src/app.d.ts
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,32 @@ | ||||
| /// <reference types="@sveltejs/kit" /> | ||||
|  | ||||
| // See https://kit.svelte.dev/docs/types#app | ||||
| // for information about these interfaces | ||||
| declare namespace App { | ||||
|   interface Locals { | ||||
|     user?: { | ||||
|       id: string, | ||||
|       email: string, | ||||
|       accessToken: string, | ||||
|       firstName: string, | ||||
|       lastName: string, | ||||
|       isAdmin: boolean, | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   // interface Platform {} | ||||
|  | ||||
|   interface Session { | ||||
|     user?: { | ||||
|       id: string, | ||||
|       email: string, | ||||
|       accessToken: string, | ||||
|       firstName: string, | ||||
|       lastName: string | ||||
|       isAdmin: boolean, | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   // interface Stuff {} | ||||
| } | ||||
|  | ||||
							
								
								
									
										12
									
								
								web/src/app.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,12 @@ | ||||
| <!DOCTYPE html> | ||||
| <html lang="en"> | ||||
| 	<head> | ||||
| 		<meta charset="utf-8" /> | ||||
| 		<link rel="icon" href="%svelte.assets%/favicon.png" /> | ||||
| 		<meta name="viewport" content="width=device-width, initial-scale=1" /> | ||||
| 		%svelte.head% | ||||
| 	</head> | ||||
| 	<body> | ||||
| 		<div>%svelte.body%</div> | ||||
| 	</body> | ||||
| </html> | ||||
							
								
								
									
										54
									
								
								web/src/hooks.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,54 @@ | ||||
| import type { ExternalFetch, GetSession, Handle } from '@sveltejs/kit'; | ||||
| import * as cookie from 'cookie'; | ||||
| import { serverEndpoint } from '$lib/constants'; | ||||
| import { session } from '$app/stores'; | ||||
|  | ||||
|  | ||||
| export const handle: Handle = async ({ event, resolve, }) => { | ||||
|   const cookies = cookie.parse(event.request.headers.get('cookie') || ''); | ||||
|  | ||||
|   if (!cookies.session) { | ||||
|     return await resolve(event) | ||||
|   } | ||||
|  | ||||
|   const { email, isAdmin, firstName, lastName, id, accessToken } = JSON.parse(cookies.session); | ||||
|  | ||||
|   const res = await fetch(`${serverEndpoint}/auth/validateToken`, { | ||||
|     method: 'POST', | ||||
|     headers: { | ||||
|       'Authorization': `Bearer ${accessToken}` | ||||
|     } | ||||
|   }) | ||||
|  | ||||
|   if (res.status === 201) { | ||||
|     event.locals.user = { | ||||
|       id, | ||||
|       accessToken, | ||||
|       firstName, | ||||
|       lastName, | ||||
|       isAdmin, | ||||
|       email | ||||
|     }; | ||||
|   } | ||||
|  | ||||
|   const response = await resolve(event); | ||||
|  | ||||
|   return response; | ||||
| }; | ||||
|  | ||||
| export const getSession: GetSession = async ({ locals }) => { | ||||
|  | ||||
|   if (!locals.user) return {} | ||||
|  | ||||
|   return { | ||||
|     user: { | ||||
|       id: locals.user.id, | ||||
|       accessToken: locals.user.accessToken, | ||||
|       firstName: locals.user.firstName, | ||||
|       lastName: locals.user.lastName, | ||||
|       isAdmin: locals.user.isAdmin, | ||||
|       email: locals.user.email | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
							
								
								
									
										53
									
								
								web/src/lib/api.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,53 @@ | ||||
| import { serverEndpoint } from './constants'; | ||||
|  | ||||
| type ISend = { | ||||
|   method: string, | ||||
|   path: string, | ||||
|   data?: any, | ||||
|   token: string | ||||
| } | ||||
|  | ||||
| type IOption = { | ||||
|   method: string, | ||||
|   headers: Record<string, string>, | ||||
|   body: any | ||||
| } | ||||
|  | ||||
| async function send({ method, path, data, token }: ISend) { | ||||
|   const opts: IOption = { method, headers: {} } as IOption; | ||||
|  | ||||
|   if (data) { | ||||
|     opts.headers['Content-Type'] = 'application/json'; | ||||
|     opts.body = JSON.stringify(data); | ||||
|   } | ||||
|  | ||||
|   if (token) { | ||||
|     opts.headers['Authorization'] = `Bearer ${token}`; | ||||
|   } | ||||
|  | ||||
|   return fetch(`${serverEndpoint}/${path}`, opts) | ||||
|     .then((r) => r.text()) | ||||
|     .then((json) => { | ||||
|       try { | ||||
|         return JSON.parse(json); | ||||
|       } catch (err) { | ||||
|         return json; | ||||
|       } | ||||
|     }); | ||||
| } | ||||
|  | ||||
| export function getRequest(path: string, token: string) { | ||||
|   return send({ method: 'GET', path, token }); | ||||
| } | ||||
|  | ||||
| export function delRequest(path: string, token: string) { | ||||
|   return send({ method: 'DELETE', path, token }); | ||||
| } | ||||
|  | ||||
| export function postRequest(path: string, data: any, token: string) { | ||||
|   return send({ method: 'POST', path, data, token }); | ||||
| } | ||||
|  | ||||
| export function putRequest(path: string, data: any, token: string) { | ||||
|   return send({ method: 'PUT', path, data, token }); | ||||
| } | ||||
							
								
								
									
										74
									
								
								web/src/lib/auth-api.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,74 @@ | ||||
| type AdminRegistrationResult = Promise<{ | ||||
|   error?: string | ||||
|   success?: string | ||||
|   user?: { | ||||
|     email: string | ||||
|   } | ||||
| }> | ||||
|  | ||||
|  | ||||
|  | ||||
| type LoginResult = Promise<{ | ||||
|   error?: string | ||||
|   success?: string | ||||
|   needUpdate?: boolean | ||||
|   needSelectAdmin?: boolean | ||||
|   user?: { | ||||
|     accessToken: string | ||||
|     firstName: string | ||||
|     lastName: string | ||||
|     isAdmin: boolean | ||||
|     id: string | ||||
|     email: string | ||||
|   } | ||||
| }> | ||||
|  | ||||
| type UpdateResult = Promise<{ | ||||
|   error?: string | ||||
|   success?: string, | ||||
|   user?: { | ||||
|     accessToken: string | ||||
|     firstName: string | ||||
|     lastName: string | ||||
|     isAdmin: boolean | ||||
|     id: string | ||||
|     email: string | ||||
|   } | ||||
| }> | ||||
|  | ||||
|  | ||||
| export async function sendRegistrationForm(form: HTMLFormElement): AdminRegistrationResult { | ||||
|  | ||||
|   const response = await fetch(form.action, { | ||||
|     method: form.method, | ||||
|     body: new FormData(form), | ||||
|     headers: { accept: 'application/json' }, | ||||
|   }) | ||||
|  | ||||
|   return await response.json() | ||||
|  | ||||
| } | ||||
|  | ||||
|  | ||||
| export async function sendLoginForm(form: HTMLFormElement): LoginResult { | ||||
|  | ||||
|   const response = await fetch(form.action, { | ||||
|     method: form.method, | ||||
|     body: new FormData(form), | ||||
|     headers: { accept: 'application/json' }, | ||||
|   }) | ||||
|  | ||||
|   return await response.json() | ||||
| } | ||||
|  | ||||
| export async function sendUpdateForm(form: HTMLFormElement): UpdateResult { | ||||
|  | ||||
|   const response = await fetch(form.action, { | ||||
|     method: form.method, | ||||
|     body: new FormData(form), | ||||
|     headers: { accept: 'application/json' }, | ||||
|   }) | ||||
|  | ||||
|   return await response.json() | ||||
| } | ||||
|  | ||||
							
								
								
									
										41
									
								
								web/src/lib/components/admin/user-management.svelte
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,41 @@ | ||||
| <script lang="ts"> | ||||
| 	import { createEventDispatcher } from 'svelte'; | ||||
| 	import PencilOutline from 'svelte-material-icons/PencilOutline.svelte'; | ||||
| 	export let usersOnServer: Array<any>; | ||||
|  | ||||
| 	const dispatch = createEventDispatcher(); | ||||
| </script> | ||||
|  | ||||
| <p class="text-sm">USER LIST</p> | ||||
|  | ||||
| <table class="text-left w-full my-4"> | ||||
| 	<thead class="border rounded-md mb-2 bg-gray-50 flex text-immich-primary w-full h-12 "> | ||||
| 		<tr class="flex w-full place-items-center"> | ||||
| 			<th class="text-center w-1/4 font-medium text-sm">Email</th> | ||||
| 			<th class="text-center w-1/4 font-medium text-sm">First name</th> | ||||
| 			<th class="text-center w-1/4 font-medium text-sm">Last name</th> | ||||
| 			<th class="text-center w-1/4 font-medium text-sm">Edit</th> | ||||
| 		</tr> | ||||
| 	</thead> | ||||
| 	<tbody class="overflow-y-auto rounded-md w-full max-h-[320px] block border"> | ||||
| 		{#each usersOnServer as user, i} | ||||
| 			<tr | ||||
| 				class={`text-center flex place-items-center w-full border-b h-[80px] ${ | ||||
| 					i % 2 == 0 ? 'bg-gray-100' : 'bg-immich-bg' | ||||
| 				}`} | ||||
| 			> | ||||
| 				<td class="text-sm px-4 w-1/4 text-ellipsis">{user.email}</td> | ||||
| 				<td class="text-sm px-4 w-1/4 text-ellipsis">{user.firstName}</td> | ||||
| 				<td class="text-sm px-4 w-1/4 text-ellipsis">{user.lastName}</td> | ||||
| 				<td class="text-sm px-4 w-1/4 text-ellipsis" | ||||
| 					><button | ||||
| 						class="bg-immich-primary text-gray-100 rounded-full p-3 transition-all duration-150 hover:bg-immich-primary/75" | ||||
| 						><PencilOutline size="20" /></button | ||||
| 					></td | ||||
| 				> | ||||
| 			</tr> | ||||
| 		{/each} | ||||
| 	</tbody> | ||||
| </table> | ||||
|  | ||||
| <button on:click={() => dispatch('createUser')} class="immich-btn-primary">Create user</button> | ||||
							
								
								
									
										78
									
								
								web/src/lib/components/forms/admin-registration-form.svelte
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,78 @@ | ||||
| <script lang="ts"> | ||||
| 	import { goto } from '$app/navigation'; | ||||
|  | ||||
| 	import { sendRegistrationForm } from '$lib/auth-api'; | ||||
| 	let error: string; | ||||
| 	let success: string; | ||||
|  | ||||
| 	async function registerAdmin(event: SubmitEvent) { | ||||
| 		error = ''; | ||||
|  | ||||
| 		const formElement = event.target as HTMLFormElement; | ||||
|  | ||||
| 		const response = await sendRegistrationForm(formElement); | ||||
|  | ||||
| 		if (response.error) { | ||||
| 			error = JSON.stringify(response.error); | ||||
| 		} | ||||
|  | ||||
| 		if (response.success) { | ||||
| 			success = response.success; | ||||
| 			goto('/auth/login'); | ||||
| 		} | ||||
| 	} | ||||
| </script> | ||||
|  | ||||
| <div class="border bg-white p-4 shadow-sm w-[500px] rounded-md py-8"> | ||||
| 	<div class="flex flex-col place-items-center place-content-center gap-4 px-4"> | ||||
| 		<img class="text-center" src="/immich-logo.svg" height="100" width="100" alt="immich-logo" /> | ||||
| 		<h1 class="text-2xl text-immich-primary font-medium">Admin Registration</h1> | ||||
| 		<p class="text-sm border rounded-md p-4 font-mono text-gray-600"> | ||||
| 			Since you are the first user on the system, you will be assigned as the Admin and are responsible for | ||||
| 			administrative tasks, and additional users will be created by you. | ||||
| 		</p> | ||||
| 	</div> | ||||
|  | ||||
| 	<form on:submit|preventDefault={registerAdmin} method="post" action="" autocomplete="off"> | ||||
| 		<div class="m-4 flex flex-col gap-2"> | ||||
| 			<label class="immich-form-label" for="email">Admin Email</label> | ||||
| 			<input class="immich-form-input" id="email" name="email" type="email" required /> | ||||
| 		</div> | ||||
|  | ||||
| 		<div class="m-4 flex flex-col gap-2"> | ||||
| 			<label class="immich-form-label" for="password">Admin Password</label> | ||||
| 			<input class="immich-form-input" id="password" name="password" type="password" required /> | ||||
| 		</div> | ||||
|  | ||||
| 		<div class="m-4 flex flex-col gap-2"> | ||||
| 			<label class="immich-form-label" for="password">First Name</label> | ||||
| 			<input class="immich-form-input" id="firstName" name="firstName" type="text" required /> | ||||
| 		</div> | ||||
|  | ||||
| 		<div class="m-4 flex flex-col gap-2"> | ||||
| 			<label class="immich-form-label" for="password">Last Name</label> | ||||
| 			<input class="immich-form-input" id="lastName" name="lastName" type="text" required /> | ||||
| 		</div> | ||||
|  | ||||
| 		{#if error} | ||||
| 			<p class="text-red-400">{error}</p> | ||||
| 		{/if} | ||||
|  | ||||
| 		{#if success} | ||||
| 			<div> | ||||
| 				<p>Admin account has been registered</p> | ||||
| 				<p> | ||||
| 					<a href="/auth/login">Login</a> | ||||
| 				</p> | ||||
| 			</div> | ||||
| 		{/if} | ||||
|  | ||||
| 		<div class="flex w-full"> | ||||
| 			<button | ||||
| 				type="submit" | ||||
| 				class="m-4 p-2 bg-immich-primary hover:bg-immich-primary/75 px-6 py-4 text-white rounded-md shadow-md w-full" | ||||
| 				>Sign Up</button | ||||
| 			> | ||||
| 		</div> | ||||
| 	</form> | ||||
| </div> | ||||
							
								
								
									
										74
									
								
								web/src/lib/components/forms/create-user-form.svelte
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,74 @@ | ||||
| <script lang="ts"> | ||||
| 	import { sendRegistrationForm } from '$lib/auth-api'; | ||||
| 	import { createEventDispatcher } from 'svelte'; | ||||
|  | ||||
| 	let error: string; | ||||
| 	let success: string; | ||||
|  | ||||
| 	const dispatch = createEventDispatcher(); | ||||
|  | ||||
| 	async function registerUser(event: SubmitEvent) { | ||||
| 		error = ''; | ||||
|  | ||||
| 		const formElement = event.target as HTMLFormElement; | ||||
|  | ||||
| 		const response = await sendRegistrationForm(formElement); | ||||
|  | ||||
| 		if (response.error) { | ||||
| 			error = JSON.stringify(response.error); | ||||
| 		} | ||||
|  | ||||
| 		if (response.success) { | ||||
| 			success = 'New user created'; | ||||
|  | ||||
| 			dispatch('user-created'); | ||||
| 		} | ||||
| 	} | ||||
| </script> | ||||
|  | ||||
| <div class="border bg-white p-4 shadow-sm w-[500px] rounded-md py-8"> | ||||
| 	<div class="flex flex-col place-items-center place-content-center gap-4 px-4"> | ||||
| 		<img class="text-center" src="/immich-logo.svg" height="100" width="100" alt="immich-logo" /> | ||||
| 		<h1 class="text-2xl text-immich-primary font-medium">Create new user</h1> | ||||
| 		<p class="text-sm border rounded-md p-4 font-mono text-gray-600"> | ||||
| 			Please provide your user with the password, they will have to change it on their first sign in. | ||||
| 		</p> | ||||
| 	</div> | ||||
|  | ||||
| 	<form on:submit|preventDefault={registerUser} method="post" action="/admin/api/create-user" autocomplete="off"> | ||||
| 		<div class="m-4 flex flex-col gap-2"> | ||||
| 			<label class="immich-form-label" for="email">Email</label> | ||||
| 			<input class="immich-form-input" id="email" name="email" type="email" required /> | ||||
| 		</div> | ||||
|  | ||||
| 		<div class="m-4 flex flex-col gap-2"> | ||||
| 			<label class="immich-form-label" for="password">Password</label> | ||||
| 			<input class="immich-form-input" id="password" name="password" type="password" required /> | ||||
| 		</div> | ||||
|  | ||||
| 		<div class="m-4 flex flex-col gap-2"> | ||||
| 			<label class="immich-form-label" for="password">First Name</label> | ||||
| 			<input class="immich-form-input" id="firstName" name="firstName" type="text" required /> | ||||
| 		</div> | ||||
|  | ||||
| 		<div class="m-4 flex flex-col gap-2"> | ||||
| 			<label class="immich-form-label" for="password">Last Name</label> | ||||
| 			<input class="immich-form-input" id="lastName" name="lastName" type="text" required /> | ||||
| 		</div> | ||||
|  | ||||
| 		{#if error} | ||||
| 			<p class="text-red-400">{error}</p> | ||||
| 		{/if} | ||||
|  | ||||
| 		{#if success} | ||||
| 			<p class="text-immich-primary">{success}</p> | ||||
| 		{/if} | ||||
| 		<div class="flex w-full"> | ||||
| 			<button | ||||
| 				type="submit" | ||||
| 				class="m-4 p-2 bg-immich-primary hover:bg-immich-primary/75 px-6 py-4 text-white rounded-md shadow-md w-full" | ||||
| 				>Create</button | ||||
| 			> | ||||
| 		</div> | ||||
| 	</form> | ||||
| </div> | ||||
							
								
								
									
										73
									
								
								web/src/lib/components/forms/login-form.svelte
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,73 @@ | ||||
| <script lang="ts"> | ||||
| 	import { goto } from '$app/navigation'; | ||||
| 	import { session } from '$app/stores'; | ||||
| 	import { sendLoginForm } from '$lib/auth-api'; | ||||
| 	import { createEventDispatcher } from 'svelte'; | ||||
|  | ||||
| 	let error: string; | ||||
| 	const dispatch = createEventDispatcher(); | ||||
|  | ||||
| 	async function login(event: SubmitEvent) { | ||||
| 		error = ''; | ||||
|  | ||||
| 		const formElement = event.target as HTMLFormElement; | ||||
|  | ||||
| 		const response = await sendLoginForm(formElement); | ||||
|  | ||||
| 		if (response.error) { | ||||
| 			error = response.error; | ||||
| 		} | ||||
|  | ||||
| 		if (response.needUpdate) { | ||||
| 			return dispatch('need-update'); | ||||
| 		} | ||||
|  | ||||
| 		if (response.needSelectAdmin) { | ||||
| 			return dispatch('need-select-admin'); | ||||
| 		} | ||||
|  | ||||
| 		if (response.success) { | ||||
| 			$session.user = { | ||||
| 				accessToken: response.user!.accessToken, | ||||
| 				firstName: response.user!.firstName, | ||||
| 				lastName: response.user!.lastName, | ||||
| 				isAdmin: response.user!.isAdmin, | ||||
| 				id: response.user!.id, | ||||
| 				email: response.user!.email, | ||||
| 			}; | ||||
|  | ||||
| 			return dispatch('success'); | ||||
| 		} | ||||
| 	} | ||||
| </script> | ||||
|  | ||||
| <div class="border bg-white p-4 shadow-sm w-[500px] rounded-md py-8"> | ||||
| 	<div class="flex flex-col place-items-center place-content-center gap-4 px-4"> | ||||
| 		<img class="text-center" src="/immich-logo.svg" height="100" width="100" alt="immich-logo" /> | ||||
| 		<h1 class="text-2xl text-immich-primary font-medium">Login</h1> | ||||
| 	</div> | ||||
|  | ||||
| 	<form on:submit|preventDefault={login} method="post" action="" autocomplete="off"> | ||||
| 		<div class="m-4 flex flex-col gap-2"> | ||||
| 			<label class="immich-form-label" for="email">Email</label> | ||||
| 			<input class="immich-form-input" id="email" name="email" type="email" required /> | ||||
| 		</div> | ||||
|  | ||||
| 		<div class="m-4 flex flex-col gap-2"> | ||||
| 			<label class="immich-form-label" for="password">Password</label> | ||||
| 			<input class="immich-form-input" id="password" name="password" type="password" required /> | ||||
| 		</div> | ||||
|  | ||||
| 		{#if error} | ||||
| 			<p class="text-red-400 pl-4">{error}</p> | ||||
| 		{/if} | ||||
|  | ||||
| 		<div class="flex w-full"> | ||||
| 			<button | ||||
| 				type="submit" | ||||
| 				class="m-4 p-2 bg-immich-primary hover:bg-immich-primary/75 px-6 py-4 text-white rounded-md shadow-md w-full font-semibold" | ||||
| 				>Login</button | ||||
| 			> | ||||
| 		</div> | ||||
| 	</form> | ||||
| </div> | ||||
							
								
								
									
										93
									
								
								web/src/lib/components/forms/select-admin-form.svelte
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,93 @@ | ||||
| <script lang="ts"> | ||||
| 	import { session } from '$app/stores'; | ||||
|  | ||||
| 	import { createEventDispatcher, onMount } from 'svelte'; | ||||
| 	import { fade } from 'svelte/transition'; | ||||
| 	import type { ImmichUser } from '../../models/immich-user'; | ||||
| 	import Check from 'svelte-material-icons/Check.svelte'; | ||||
|  | ||||
| 	let error: string = ''; | ||||
| 	let allUsers: Array<ImmichUser> = []; | ||||
| 	let selectedUserId: string; | ||||
| 	const dispatch = createEventDispatcher(); | ||||
|  | ||||
| 	onMount(async () => { | ||||
| 		const res = await fetch('/auth/login/api/get-users', { method: 'GET' }); | ||||
| 		const data = await res.json(); | ||||
| 		allUsers = data.allUsers; | ||||
| 	}); | ||||
|  | ||||
| 	const assignAdmin = async () => { | ||||
| 		const res = await fetch('/auth/login/api/select-admin', { | ||||
| 			method: 'POST', | ||||
| 			body: JSON.stringify({ | ||||
| 				id: selectedUserId, | ||||
| 				isAdmin: true, | ||||
| 			}), | ||||
| 		}); | ||||
|  | ||||
| 		if (res.status === 200) { | ||||
| 			const data = await res.json(); | ||||
|  | ||||
| 			$session.user = { | ||||
| 				accessToken: '', | ||||
| 				firstName: data.userInfo.firstName, | ||||
| 				lastName: data.userInfo.lastName, | ||||
| 				isAdmin: data.userInfo.isAdmin, | ||||
| 				id: data.userInfo.id, | ||||
| 				email: data.userInfo.email, | ||||
| 			}; | ||||
|  | ||||
| 			dispatch('success'); | ||||
| 		} else { | ||||
| 			error = JSON.stringify(await res.json()); | ||||
| 		} | ||||
| 	}; | ||||
| </script> | ||||
|  | ||||
| <div class="border bg-white p-4 shadow-sm w-[500px] rounded-md py-8"> | ||||
| 	<div class="flex flex-col place-items-center place-content-center gap-4 px-4"> | ||||
| 		<img class="text-center" src="/immich-logo.svg" height="100" width="100" alt="immich-logo" /> | ||||
| 		<h1 class="text-2xl text-immich-primary font-medium">Select Admin</h1> | ||||
| 		<p class="text-sm border rounded-md p-4 font-mono text-gray-600"> | ||||
| 			There are multiple users on the server, and none have been selected to be the admin. Please assign one as the | ||||
| 			admin, who will be responsible for administrative tasks | ||||
| 		</p> | ||||
| 	</div> | ||||
|  | ||||
| 	<div class="text-xs m-4">USERS ON SERVER, CLICK TO SELECT ONE</div> | ||||
| 	<div class="overflow-y-auto rounded-md max-h-[300px] block border mx-4 px-4 py-2"> | ||||
| 		{#each allUsers as user, i} | ||||
| 			<div | ||||
| 				class="p-4 flex justify-between place-items-center my-4 rounded-md hover:cursor-pointer shadow-sm bg-gray-50 hover:bg-gray-100" | ||||
| 				on:click={() => (selectedUserId = user.id)} | ||||
| 			> | ||||
| 				<p class="test-sm text-slate-600">{i + 1} | {user.email}</p> | ||||
|  | ||||
| 				<!-- Icon --> | ||||
| 				{#if selectedUserId == user.id} | ||||
| 					<div | ||||
| 						in:fade={{ duration: 100 }} | ||||
| 						class="border rounded-full border-gray-300 bg-immich-primary w-8 h-8 flex place-items-center place-content-center" | ||||
| 					> | ||||
| 						<Check color="white" size="24" /> | ||||
| 					</div> | ||||
| 				{:else} | ||||
| 					<div in:fade={{ duration: 100 }} class="border rounded-full border-gray-300 w-8 h-8" /> | ||||
| 				{/if} | ||||
| 			</div> | ||||
| 		{/each} | ||||
| 	</div> | ||||
|  | ||||
| 	{#if error} | ||||
| 		<div class="text-xs m-4 text-red-400">Error: {error}</div> | ||||
| 	{/if} | ||||
|  | ||||
| 	<div class="flex w-full"> | ||||
| 		<button | ||||
| 			type="submit" | ||||
| 			class="m-4 p-2 bg-immich-primary hover:bg-immich-primary/75 px-6 py-4 text-white rounded-md shadow-md w-full font-semibold" | ||||
| 			on:click={assignAdmin}>Assign as Admin</button | ||||
| 		> | ||||
| 	</div> | ||||
| </div> | ||||
							
								
								
									
										68
									
								
								web/src/lib/components/forms/update-form.svelte
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,68 @@ | ||||
| <script lang="ts"> | ||||
| 	import { goto } from '$app/navigation'; | ||||
| 	import { session } from '$app/stores'; | ||||
| 	import { sendUpdateForm } from '$lib/auth-api'; | ||||
| 	import { createEventDispatcher } from 'svelte'; | ||||
|  | ||||
| 	let error: string; | ||||
| 	const dispatch = createEventDispatcher(); | ||||
|  | ||||
| 	async function updateInfo(event: SubmitEvent) { | ||||
| 		error = ''; | ||||
|  | ||||
| 		const formElement = event.target as HTMLFormElement; | ||||
|  | ||||
| 		const response = await sendUpdateForm(formElement); | ||||
|  | ||||
| 		if (response.error) { | ||||
| 			error = response.error; | ||||
| 		} | ||||
|  | ||||
| 		if (response.success) { | ||||
| 			$session.user = { | ||||
| 				accessToken: response.user!.accessToken, | ||||
| 				firstName: response.user!.firstName, | ||||
| 				lastName: response.user!.lastName, | ||||
| 				isAdmin: response.user!.isAdmin, | ||||
| 				id: response.user!.id, | ||||
| 				email: response.user!.email, | ||||
| 			}; | ||||
|  | ||||
| 			dispatch('success'); | ||||
| 		} | ||||
| 	} | ||||
| </script> | ||||
|  | ||||
| <div class="border bg-white p-4 shadow-sm w-[500px] rounded-md py-8"> | ||||
| 	<div class="flex flex-col place-items-center place-content-center gap-4 px-4"> | ||||
| 		<img class="text-center" src="/immich-logo.svg" height="100" width="100" alt="immich-logo" /> | ||||
| 		<h1 class="text-2xl text-immich-primary font-medium">Update User Info</h1> | ||||
| 		<p class="text-sm border rounded-md p-4 font-mono text-gray-600"> | ||||
| 			Your account doesn't have information about your name, please update to continue the login process. | ||||
| 		</p> | ||||
| 	</div> | ||||
|  | ||||
| 	<form on:submit|preventDefault={updateInfo} method="post" action="/auth/login/update" autocomplete="off"> | ||||
| 		<div class="m-4 flex flex-col gap-2"> | ||||
| 			<label class="immich-form-label" for="firstName">First name</label> | ||||
| 			<input class="immich-form-input" id="firstName" name="firstName" type="text" required /> | ||||
| 		</div> | ||||
|  | ||||
| 		<div class="m-4 flex flex-col gap-2"> | ||||
| 			<label class="immich-form-label" for="lastName">Last name</label> | ||||
| 			<input class="immich-form-input" id="lastName" name="lastName" type="text" required /> | ||||
| 		</div> | ||||
|  | ||||
| 		{#if error} | ||||
| 			<p class="text-red-400 pl-4">{error}</p> | ||||
| 		{/if} | ||||
|  | ||||
| 		<div class="flex w-full"> | ||||
| 			<button | ||||
| 				type="submit" | ||||
| 				class="m-4 p-2 bg-immich-primary hover:bg-immich-primary/75 px-6 py-4 text-white rounded-md shadow-md w-full font-semibold" | ||||
| 				>Update</button | ||||
| 			> | ||||
| 		</div> | ||||
| 	</form> | ||||
| </div> | ||||
							
								
								
									
										15
									
								
								web/src/lib/components/shared/click-outside.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,15 @@ | ||||
| export function clickOutside(node: Node) { | ||||
|   const handleClick = (event: any) => { | ||||
|     if (!node.contains(event.target)) { | ||||
|       node.dispatchEvent(new CustomEvent("outclick")); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   document.addEventListener("click", handleClick, true); | ||||
|  | ||||
|   return { | ||||
|     destroy() { | ||||
|       document.removeEventListener("click", handleClick, true); | ||||
|     } | ||||
|   }; | ||||
| } | ||||
							
								
								
									
										17
									
								
								web/src/lib/components/shared/full-screen-modal.svelte
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,17 @@ | ||||
| <script lang="ts"> | ||||
| 	import { clickOutside } from './click-outside'; | ||||
| 	import { createEventDispatcher } from 'svelte'; | ||||
| 	import { fade } from 'svelte/transition'; | ||||
|  | ||||
| 	const dispatch = createEventDispatcher(); | ||||
| </script> | ||||
|  | ||||
| <section | ||||
| 	in:fade={{ duration: 100 }} | ||||
| 	out:fade={{ duration: 100 }} | ||||
| 	class="absolute w-full h-full bg-black/40 z-[100] flex place-items-center place-content-center " | ||||
| > | ||||
| 	<div class="bg-immich-bg z-[9999] rounded-md" use:clickOutside on:outclick={() => dispatch('clickOutside')}> | ||||
| 		<slot /> | ||||
| 	</div> | ||||
| </section> | ||||
							
								
								
									
										60
									
								
								web/src/lib/components/shared/navigation-bar.svelte
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,60 @@ | ||||
| <script lang="ts"> | ||||
| 	import { page } from '$app/stores'; | ||||
| 	import type { ImmichUser } from '$lib/models/immich-user'; | ||||
| 	import { fade } from 'svelte/transition'; | ||||
|  | ||||
| 	export let user: ImmichUser; | ||||
|  | ||||
| 	let shouldShowAccountInfo = false; | ||||
|  | ||||
| 	const getFirstLetter = (text?: string) => { | ||||
| 		return text?.charAt(0).toUpperCase(); | ||||
| 	}; | ||||
| </script> | ||||
|  | ||||
| <section id="dashboard-navbar" class="fixed w-screen  z-[100] bg-immich-bg text-sm"> | ||||
| 	<div class="flex border place-items-center px-6 py-2 "> | ||||
| 		<a class="flex gap-2 place-items-center hover:cursor-pointer" href="/photos"> | ||||
| 			<img src="/immich-logo.svg" alt="immich logo" height="35" width="35" /> | ||||
| 			<h1 class="font-immich-title text-2xl text-immich-primary">Immich</h1> | ||||
| 		</a> | ||||
| 		<div class="flex-1 ml-24"> | ||||
| 			<div class="w-[50%] border rounded-md bg-gray-200 px-8 py-4">Search</div> | ||||
| 		</div> | ||||
| 		<section class="flex gap-6 place-items-center"> | ||||
| 			<!-- <div>Upload</div> --> | ||||
|  | ||||
| 			{#if user.isAdmin} | ||||
| 				<a | ||||
| 					class={`hover:text-immich-primary font-medium ${ | ||||
| 						$page.url.pathname == '/admin' && 'text-immich-primary underline' | ||||
| 					}`} | ||||
| 					href="/admin">Administration</a | ||||
| 				> | ||||
| 			{/if} | ||||
|  | ||||
| 			<div | ||||
| 				on:mouseover={() => (shouldShowAccountInfo = true)} | ||||
| 				on:focus={() => (shouldShowAccountInfo = true)} | ||||
| 				on:mouseleave={() => (shouldShowAccountInfo = false)} | ||||
| 			> | ||||
| 				<button | ||||
| 					class="flex place-items-center place-content-center rounded-full bg-immich-primary/80 h-10 w-10 text-gray-100 hover:bg-immich-primary" | ||||
| 				> | ||||
| 					{getFirstLetter(user.firstName)}{getFirstLetter(user.lastName)} | ||||
| 				</button> | ||||
|  | ||||
| 				{#if shouldShowAccountInfo} | ||||
| 					<div | ||||
| 						in:fade={{ delay: 500, duration: 150 }} | ||||
| 						out:fade={{ delay: 200, duration: 150 }} | ||||
| 						class="absolute -bottom-12 right-5 border bg-gray-500 text-[12px] text-gray-100 p-2 rounded-md shadow-md" | ||||
| 					> | ||||
| 						<p>{user.firstName} {user.lastName}</p> | ||||
| 						<p>{user.email}</p> | ||||
| 					</div> | ||||
| 				{/if} | ||||
| 			</div> | ||||
| 		</section> | ||||
| 	</div> | ||||
| </section> | ||||
							
								
								
									
										27
									
								
								web/src/lib/components/shared/side-bar-button.svelte
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,27 @@ | ||||
| <script lang="ts"> | ||||
| 	export let title: string; | ||||
| 	export let logo: any; | ||||
| 	export let actionType: AdminSideBarSelection | AppSideBarSelection; | ||||
| 	export let isSelected: boolean; | ||||
|  | ||||
| 	import { createEventDispatcher } from 'svelte'; | ||||
| 	import type { AdminSideBarSelection, AppSideBarSelection } from '../../models/admin-sidebar-selection'; | ||||
|  | ||||
| 	const dispatch = createEventDispatcher(); | ||||
|  | ||||
| 	const onButtonClicked = () => { | ||||
| 		dispatch('selected', { | ||||
| 			actionType, | ||||
| 		}); | ||||
| 	}; | ||||
| </script> | ||||
|  | ||||
| <div | ||||
| 	on:click={onButtonClicked} | ||||
| 	class={`flex gap-4 place-items-center pl-5 py-3 rounded-tr-xl rounded-br-xl hover:bg-gray-200 hover:text-immich-primary hover:cursor-pointer | ||||
|     ${isSelected && 'bg-immich-primary/10 text-immich-primary hover:bg-immich-primary/50'} | ||||
|   `} | ||||
| > | ||||
| 	<svelte:component this={logo} size="24" /> | ||||
| 	<p class="font-medium text-sm">{title}</p> | ||||
| </div> | ||||
							
								
								
									
										1
									
								
								web/src/lib/constants.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| export const serverEndpoint = import.meta.env.VITE_SERVER_ENDPOINT | ||||
							
								
								
									
										9
									
								
								web/src/lib/models/admin-sidebar-selection.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,9 @@ | ||||
| export enum AdminSideBarSelection { | ||||
|   USER_MANAGEMENT = "User management", | ||||
|  | ||||
| } | ||||
|  | ||||
| export enum AppSideBarSelection { | ||||
|   PHOTOS = "Photos", | ||||
|   EXPLORE = "Explore", | ||||
| } | ||||
							
								
								
									
										7
									
								
								web/src/lib/models/immich-user.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,7 @@ | ||||
| export type ImmichUser = { | ||||
|   id: string, | ||||
|   email: string, | ||||
|   firstName: string, | ||||
|   lastName: string, | ||||
|   isAdmin: boolean, | ||||
| } | ||||
							
								
								
									
										39
									
								
								web/src/routes/__layout.svelte
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,39 @@ | ||||
| <script lang="ts"> | ||||
| 	import { getRequest } from '$lib/api'; | ||||
| 	import { onDestroy } from 'svelte'; | ||||
| 	import { fade } from 'svelte/transition'; | ||||
| 	import '../app.css'; | ||||
| 	import { serverEndpoint } from '../lib/constants'; | ||||
|  | ||||
| 	let endpoint = serverEndpoint; | ||||
| 	let isServerOk = true; | ||||
|  | ||||
| 	const pingServerInterval = setInterval(async () => { | ||||
| 		const response = await getRequest('server-info/ping', ''); | ||||
|  | ||||
| 		if (response.res === 'pong') isServerOk = true; | ||||
| 		if (response.statusCode === 404) isServerOk = false; | ||||
| 	}, 10000); | ||||
|  | ||||
| 	onDestroy(() => clearInterval(pingServerInterval)); | ||||
| </script> | ||||
|  | ||||
| <main> | ||||
| 	<slot /> | ||||
| </main> | ||||
|  | ||||
| <footer | ||||
| 	class="text-sm fixed bottom-0 h-8 flex place-items-center place-content-center bg-immich-primary/10 w-screen font-mono gap-8 px-4 font-medium" | ||||
| > | ||||
| 	<p class=""> | ||||
| 		Server URL <span class="text-immich-primary font-bold">{endpoint}</span> | ||||
| 	</p> | ||||
| 	<p class=""> | ||||
| 		Server Status | ||||
| 		{#if isServerOk} | ||||
| 			<span class="text-immich-primary font-bold">OK</span> | ||||
| 		{:else} | ||||
| 			<span class="text-red-500 font-bold">OFFLINE</span> | ||||
| 		{/if} | ||||
| 	</p> | ||||
| </footer> | ||||
							
								
								
									
										44
									
								
								web/src/routes/admin/api/create-user.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,44 @@ | ||||
| import type { RequestHandler } from '@sveltejs/kit'; | ||||
| import { serverEndpoint } from '$lib/constants'; | ||||
|  | ||||
| export const post: RequestHandler = async ({ request, locals }) => { | ||||
|   const form = await request.formData(); | ||||
|  | ||||
|   const email = form.get('email') | ||||
|   const password = form.get('password') | ||||
|   const firstName = form.get('firstName') | ||||
|   const lastName = form.get('lastName') | ||||
|  | ||||
|   const payload = { | ||||
|     email, | ||||
|     password, | ||||
|     firstName, | ||||
|     lastName, | ||||
|   } | ||||
|  | ||||
|   const res = await fetch(`${serverEndpoint}/user`, { | ||||
|     method: 'POST', | ||||
|     headers: { | ||||
|       'Content-Type': 'application/json', | ||||
|       'Authorization': `Bearer ${locals.user?.accessToken}` | ||||
|     }, | ||||
|     body: JSON.stringify(payload), | ||||
|   }) | ||||
|  | ||||
|   if (res.status === 201) { | ||||
|     return { | ||||
|       status: 201, | ||||
|       body: { | ||||
|         success: 'Succesfully create user account' | ||||
|       } | ||||
|     } | ||||
|   } else { | ||||
|     return { | ||||
|       status: 400, | ||||
|       body: { | ||||
|         error: await res.json() | ||||
|       } | ||||
|     } | ||||
|  | ||||
|   } | ||||
| } | ||||
							
								
								
									
										100
									
								
								web/src/routes/admin/index.svelte
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,100 @@ | ||||
| <script context="module" lang="ts"> | ||||
| 	import type { Load } from '@sveltejs/kit'; | ||||
| 	import { getRequest } from '$lib/api'; | ||||
|  | ||||
| 	export const load: Load = async ({ session, fetch }) => { | ||||
| 		if (!session.user) { | ||||
| 			return { | ||||
| 				status: 302, | ||||
| 				redirect: '/auth/login', | ||||
| 			}; | ||||
| 		} | ||||
|  | ||||
| 		const usersOnServer = await getRequest('user', session.user.accessToken); | ||||
|  | ||||
| 		return { | ||||
| 			status: 200, | ||||
| 			props: { | ||||
| 				user: session.user, | ||||
| 				usersOnServer, | ||||
| 			}, | ||||
| 		}; | ||||
| 	}; | ||||
| </script> | ||||
|  | ||||
| <script lang="ts"> | ||||
| 	import { onMount } from 'svelte'; | ||||
| 	import { session } from '$app/stores'; | ||||
|  | ||||
| 	import type { ImmichUser } from '$lib/models/immich-user'; | ||||
| 	import { AdminSideBarSelection } from '$lib/models/admin-sidebar-selection'; | ||||
| 	import SideBarButton from '$lib/components/shared/side-bar-button.svelte'; | ||||
| 	import AccountMultipleOutline from 'svelte-material-icons/AccountMultipleOutline.svelte'; | ||||
| 	import NavigationBar from '$lib/components/shared/navigation-bar.svelte'; | ||||
| 	import UserManagement from '$lib/components/admin/user-management.svelte'; | ||||
| 	import FullScreenModal from '$lib/components/shared/full-screen-modal.svelte'; | ||||
| 	import CreateUserForm from '$lib/components/forms/create-user-form.svelte'; | ||||
|  | ||||
| 	let selectedAction: AdminSideBarSelection; | ||||
|  | ||||
| 	export let user: ImmichUser; | ||||
| 	export let usersOnServer: Array<ImmichUser>; | ||||
|  | ||||
| 	let shouldShowCreateUserForm: boolean; | ||||
|  | ||||
| 	const onButtonClicked = (buttonType: CustomEvent) => { | ||||
| 		selectedAction = buttonType.detail['actionType'] as AdminSideBarSelection; | ||||
| 	}; | ||||
|  | ||||
| 	onMount(() => { | ||||
| 		selectedAction = AdminSideBarSelection.USER_MANAGEMENT; | ||||
| 	}); | ||||
|  | ||||
| 	const onUserCreated = async () => { | ||||
| 		if ($session.user) { | ||||
| 			usersOnServer = await getRequest('user', $session.user.accessToken); | ||||
| 		} | ||||
|  | ||||
| 		shouldShowCreateUserForm = false; | ||||
| 	}; | ||||
| </script> | ||||
|  | ||||
| <svelte:head> | ||||
| 	<title>Immich - Administration</title> | ||||
| </svelte:head> | ||||
|  | ||||
| <NavigationBar {user} /> | ||||
|  | ||||
| {#if shouldShowCreateUserForm} | ||||
| 	<FullScreenModal on:clickOutside={() => (shouldShowCreateUserForm = false)}> | ||||
| 		<div> | ||||
| 			<CreateUserForm on:user-created={onUserCreated} /> | ||||
| 		</div> | ||||
| 	</FullScreenModal> | ||||
| {/if} | ||||
|  | ||||
| <section class="grid grid-cols-[250px_auto] relative pt-[72px] h-screen"> | ||||
| 	<section id="admin-sidebar" class="pt-8 pr-6"> | ||||
| 		<SideBarButton | ||||
| 			title="User" | ||||
| 			logo={AccountMultipleOutline} | ||||
| 			actionType={AdminSideBarSelection.USER_MANAGEMENT} | ||||
| 			isSelected={selectedAction === AdminSideBarSelection.USER_MANAGEMENT} | ||||
| 			on:selected={onButtonClicked} | ||||
| 		/> | ||||
| 	</section> | ||||
| 	<section class="overflow-y-auto relative"> | ||||
| 		<div id="setting-title" class="pt-10 fixed w-full z-50 bg-immich-bg"> | ||||
| 			<h1 class="text-lg ml-8 mb-4 text-immich-primary font-medium">{selectedAction}</h1> | ||||
| 			<hr /> | ||||
| 		</div> | ||||
|  | ||||
| 		<section id="setting-content" class="relative pt-[85px] flex place-content-center"> | ||||
| 			<section class="w-[800px] pt-4"> | ||||
| 				{#if selectedAction === AdminSideBarSelection.USER_MANAGEMENT} | ||||
| 					<UserManagement {usersOnServer} on:createUser={() => (shouldShowCreateUserForm = true)} /> | ||||
| 				{/if} | ||||
| 			</section> | ||||
| 		</section> | ||||
| 	</section> | ||||
| </section> | ||||
							
								
								
									
										12
									
								
								web/src/routes/auth/login/api/get-users.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,12 @@ | ||||
| import type { RequestHandler } from '@sveltejs/kit'; | ||||
| import { getRequest } from '../../../../lib/api'; | ||||
|  | ||||
|  | ||||
| export const get: RequestHandler = async ({ request, locals }) => { | ||||
|   const allUsers = await getRequest('user?isAll=true', locals.user!.accessToken) | ||||
|  | ||||
|   return { | ||||
|     status: 200, | ||||
|     body: { allUsers } | ||||
|   }; | ||||
| } | ||||
							
								
								
									
										52
									
								
								web/src/routes/auth/login/api/select-admin.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,52 @@ | ||||
| import type { RequestHandler } from '@sveltejs/kit'; | ||||
| import { putRequest } from '$lib/api'; | ||||
| import * as cookie from 'cookie'; | ||||
|  | ||||
| export const post: RequestHandler = async ({ request, locals }) => { | ||||
|  | ||||
|   const { id, isAdmin } = await request.json() | ||||
|  | ||||
|   const res = await putRequest('user', { | ||||
|     id, | ||||
|     isAdmin, | ||||
|   }, locals.user!.accessToken); | ||||
|  | ||||
|  | ||||
|  | ||||
|   if (res.statusCode) { | ||||
|     return { | ||||
|       status: res.statusCode, | ||||
|       body: JSON.stringify(res) | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   if (res.id == locals.user!.id) { | ||||
|     return { | ||||
|       status: 200, | ||||
|       body: { userInfo: res }, | ||||
|       headers: { | ||||
|         'Set-Cookie': cookie.serialize('session', JSON.stringify( | ||||
|           { | ||||
|             id: res.id, | ||||
|             accessToken: locals.user!.accessToken, | ||||
|             firstName: res.firstName, | ||||
|             lastName: res.lastName, | ||||
|             isAdmin: res.isAdmin, | ||||
|             email: res.email, | ||||
|           }), { | ||||
|           path: '/', | ||||
|           httpOnly: true, | ||||
|           sameSite: 'strict', | ||||
|           maxAge: 60 * 60 * 24 * 30, | ||||
|         }) | ||||
|       } | ||||
|     } | ||||
|   } else { | ||||
|     return { | ||||
|       status: 200, | ||||
|       body: { userInfo: { ...locals.user! } }, | ||||
|     } | ||||
|   } | ||||
|  | ||||
|  | ||||
| } | ||||
							
								
								
									
										49
									
								
								web/src/routes/auth/login/index.svelte
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,49 @@ | ||||
| <script lang="ts"> | ||||
| 	import { goto } from '$app/navigation'; | ||||
| 	import { fade } from 'svelte/transition'; | ||||
|  | ||||
| 	import LoginForm from '$lib/components/forms/login-form.svelte'; | ||||
| 	import UpdateForm from '../../../lib/components/forms/update-form.svelte'; | ||||
| 	import SelectAdminForm from '../../../lib/components/forms/select-admin-form.svelte'; | ||||
|  | ||||
| 	let shouldShowUpdateForm = false; | ||||
| 	let shouldShowSelectAdminForm = false; | ||||
|  | ||||
| 	const onLoginSuccess = async () => { | ||||
| 		goto('/photos'); | ||||
| 	}; | ||||
|  | ||||
| 	const onNeedUpdate = () => { | ||||
| 		shouldShowUpdateForm = true; | ||||
| 		shouldShowSelectAdminForm = false; | ||||
| 	}; | ||||
|  | ||||
| 	const onNeedSelectAdmin = () => { | ||||
| 		shouldShowUpdateForm = false; | ||||
| 		shouldShowSelectAdminForm = true; | ||||
| 	}; | ||||
| </script> | ||||
|  | ||||
| <svelte:head> | ||||
| 	<title>Immich - Login</title> | ||||
| </svelte:head> | ||||
|  | ||||
| <section class="h-screen w-screen flex place-items-center place-content-center"> | ||||
| 	{#if !shouldShowUpdateForm && !shouldShowSelectAdminForm} | ||||
| 		<div in:fade={{ duration: 100 }} out:fade={{ duration: 100 }}> | ||||
| 			<LoginForm on:success={onLoginSuccess} on:need-update={onNeedUpdate} on:need-select-admin={onNeedSelectAdmin} /> | ||||
| 		</div> | ||||
| 	{/if} | ||||
|  | ||||
| 	{#if shouldShowUpdateForm} | ||||
| 		<div in:fade={{ duration: 100 }} out:fade={{ duration: 100 }}> | ||||
| 			<UpdateForm on:success={onLoginSuccess} /> | ||||
| 		</div> | ||||
| 	{/if} | ||||
|  | ||||
| 	{#if shouldShowSelectAdminForm} | ||||
| 		<div in:fade={{ duration: 100 }} out:fade={{ duration: 100 }}> | ||||
| 			<SelectAdminForm on:success={onLoginSuccess} /> | ||||
| 		</div> | ||||
| 	{/if} | ||||
| </section> | ||||
							
								
								
									
										229
									
								
								web/src/routes/auth/login/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,229 @@ | ||||
| import type { RequestHandler } from '@sveltejs/kit'; | ||||
| import { serverEndpoint } from '$lib/constants'; | ||||
| import * as cookie from 'cookie' | ||||
| import { getRequest, putRequest } from '$lib/api'; | ||||
|  | ||||
| type LoggedInUser = { | ||||
|   accessToken: string; | ||||
|   userId: string; | ||||
|   userEmail: string; | ||||
|   firstName: string; | ||||
|   lastName: string; | ||||
|   isAdmin: boolean; | ||||
| } | ||||
|  | ||||
| export const post: RequestHandler = async ({ request }) => { | ||||
|   const form = await request.formData(); | ||||
|  | ||||
|   const email = form.get('email') | ||||
|   const password = form.get('password') | ||||
|  | ||||
|   const payload = { | ||||
|     email, | ||||
|     password, | ||||
|   } | ||||
|  | ||||
|   const res = await fetch(`${serverEndpoint}/auth/login`, { | ||||
|     method: 'POST', | ||||
|     headers: { | ||||
|       'Content-Type': 'application/json' | ||||
|     }, | ||||
|     body: JSON.stringify(payload), | ||||
|   }) | ||||
|  | ||||
|   if (res.status === 201) { | ||||
|     // Login success | ||||
|     const loggedInUser = await res.json() as LoggedInUser; | ||||
|  | ||||
|     /** | ||||
|      * Support legacy users with two scenario | ||||
|      *  | ||||
|      * Scenario 1 - If one user exists on the server - make the user admin and ask for name. | ||||
|      * Scenario 2 - After assigned as admin, scenario 1 user not complete update form with names | ||||
|      * Scenario 3 - If two users exists on the server and no admin - ask to choose which one will be made admin | ||||
|      */ | ||||
|  | ||||
|  | ||||
|     // check how many user on the server | ||||
|     const { userCount } = await getRequest('user/count', ''); | ||||
|     const { userCount: adminUserCount } = await getRequest('user/count?isAdmin=true', '') | ||||
|     /** | ||||
|      * Scenario 1 handler | ||||
|      */ | ||||
|     if (userCount == 1 && !loggedInUser.isAdmin) { | ||||
|  | ||||
|       const updatedUser = await putRequest('user', { | ||||
|         id: loggedInUser.userId, | ||||
|         isAdmin: true | ||||
|       }, loggedInUser.accessToken) | ||||
|  | ||||
|  | ||||
|       /** | ||||
|       * Scenario 2 handler for current admin user | ||||
|       */ | ||||
|       let bodyResponse = { success: true, needUpdate: false } | ||||
|  | ||||
|       if (loggedInUser.firstName == "" || loggedInUser.lastName == "") { | ||||
|         bodyResponse = { success: false, needUpdate: true } | ||||
|       } | ||||
|  | ||||
|  | ||||
|       return { | ||||
|         status: 200, | ||||
|         body: { | ||||
|           ...bodyResponse, | ||||
|           user: { | ||||
|             id: updatedUser.userId, | ||||
|             accessToken: loggedInUser.accessToken, | ||||
|             firstName: updatedUser.firstName, | ||||
|             lastName: updatedUser.lastName, | ||||
|             isAdmin: updatedUser.isAdmin, | ||||
|             email: updatedUser.email, | ||||
|           }, | ||||
|         }, | ||||
|         headers: { | ||||
|           'Set-Cookie': cookie.serialize('session', JSON.stringify( | ||||
|             { | ||||
|               id: updatedUser.userId, | ||||
|               accessToken: loggedInUser.accessToken, | ||||
|               firstName: updatedUser.firstName, | ||||
|               lastName: updatedUser.lastName, | ||||
|               isAdmin: updatedUser.isAdmin, | ||||
|               email: updatedUser.email, | ||||
|             }), { | ||||
|             path: '/', | ||||
|             httpOnly: true, | ||||
|             sameSite: 'strict', | ||||
|             maxAge: 60 * 60 * 24 * 30, | ||||
|           }) | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|  | ||||
|  | ||||
|     /** | ||||
|     * Scenario 3 handler | ||||
|     */ | ||||
|     if (userCount >= 2 && adminUserCount == 0) { | ||||
|       return { | ||||
|         status: 200, | ||||
|         body: { | ||||
|           needSelectAdmin: true, | ||||
|           user: { | ||||
|             id: loggedInUser.userId, | ||||
|             accessToken: loggedInUser.accessToken, | ||||
|             firstName: loggedInUser.firstName, | ||||
|             lastName: loggedInUser.lastName, | ||||
|             isAdmin: loggedInUser.isAdmin, | ||||
|             email: loggedInUser.userEmail | ||||
|           }, | ||||
|           success: 'success' | ||||
|         }, | ||||
|         headers: { | ||||
|           'Set-Cookie': cookie.serialize('session', JSON.stringify( | ||||
|             { | ||||
|               id: loggedInUser.userId, | ||||
|               accessToken: loggedInUser.accessToken, | ||||
|               firstName: loggedInUser.firstName, | ||||
|               lastName: loggedInUser.lastName, | ||||
|               isAdmin: loggedInUser.isAdmin, | ||||
|               email: loggedInUser.userEmail | ||||
|             }), { | ||||
|             path: '/', | ||||
|             httpOnly: true, | ||||
|             sameSite: 'strict', | ||||
|             maxAge: 60 * 60 * 24 * 30, | ||||
|           }) | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|     * Scenario 2 handler | ||||
|     */ | ||||
|     if (loggedInUser.firstName == "" || loggedInUser.lastName == "") { | ||||
|       return { | ||||
|         status: 200, | ||||
|         body: { | ||||
|           needUpdate: true, | ||||
|           user: { | ||||
|             id: loggedInUser.userId, | ||||
|             accessToken: loggedInUser.accessToken, | ||||
|             firstName: loggedInUser.firstName, | ||||
|             lastName: loggedInUser.lastName, | ||||
|             isAdmin: loggedInUser.isAdmin, | ||||
|             email: loggedInUser.userEmail | ||||
|           }, | ||||
|         }, | ||||
|         headers: { | ||||
|           'Set-Cookie': cookie.serialize('session', JSON.stringify( | ||||
|             { | ||||
|               id: loggedInUser.userId, | ||||
|               accessToken: loggedInUser.accessToken, | ||||
|               firstName: loggedInUser.firstName, | ||||
|               lastName: loggedInUser.lastName, | ||||
|               isAdmin: loggedInUser.isAdmin, | ||||
|               email: loggedInUser.userEmail | ||||
|             }), { | ||||
|             path: '/', | ||||
|             httpOnly: true, | ||||
|             sameSite: 'strict', | ||||
|             maxAge: 60 * 60 * 24 * 30, | ||||
|           }) | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|  | ||||
|  | ||||
|  | ||||
|     return { | ||||
|       status: 200, | ||||
|       body: { | ||||
|         user: { | ||||
|           id: loggedInUser.userId, | ||||
|           accessToken: loggedInUser.accessToken, | ||||
|           firstName: loggedInUser.firstName, | ||||
|           lastName: loggedInUser.lastName, | ||||
|           isAdmin: loggedInUser.isAdmin, | ||||
|           email: loggedInUser.userEmail | ||||
|         }, | ||||
|         success: 'success' | ||||
|       }, | ||||
|       headers: { | ||||
|         'Set-Cookie': cookie.serialize('session', JSON.stringify( | ||||
|           { | ||||
|             id: loggedInUser.userId, | ||||
|             accessToken: loggedInUser.accessToken, | ||||
|             firstName: loggedInUser.firstName, | ||||
|             lastName: loggedInUser.lastName, | ||||
|             isAdmin: loggedInUser.isAdmin, | ||||
|             email: loggedInUser.userEmail, | ||||
|           }), { | ||||
|           // send cookie for every page | ||||
|           path: '/', | ||||
|  | ||||
|           // server side only cookie so you can't use `document.cookie` | ||||
|           httpOnly: true, | ||||
|  | ||||
|           // only requests from same site can send cookies | ||||
|           // and serves to protect from CSRF | ||||
|           // https://developer.mozilla.org/en-US/docs/Glossary/CSRF | ||||
|           sameSite: 'strict', | ||||
|  | ||||
|           // set cookie to expire after a month | ||||
|           maxAge: 60 * 60 * 24 * 30, | ||||
|         }) | ||||
|       } | ||||
|     } | ||||
|  | ||||
|   } else { | ||||
|     return { | ||||
|       status: 400, | ||||
|       body: { | ||||
|         error: 'Incorrect email or password' | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|  | ||||
| } | ||||
							
								
								
									
										63
									
								
								web/src/routes/auth/login/update.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,63 @@ | ||||
| import type { RequestHandler } from '@sveltejs/kit'; | ||||
| import { putRequest } from '../../../lib/api'; | ||||
| import * as cookie from 'cookie' | ||||
|  | ||||
|  | ||||
| export const post: RequestHandler = async ({ request, locals }) => { | ||||
|  | ||||
|   const form = await request.formData(); | ||||
|  | ||||
|   const firstName = form.get('firstName') | ||||
|   const lastName = form.get('lastName') | ||||
|  | ||||
|   if (locals.user) { | ||||
|     const updatedUser = await putRequest('user', { | ||||
|       id: locals.user.id, | ||||
|       firstName, | ||||
|       lastName | ||||
|     }, locals.user.accessToken) | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
|     return { | ||||
|       status: 200, | ||||
|       body: { | ||||
|         user: { | ||||
|           id: updatedUser.id, | ||||
|           accessToken: locals.user.accessToken, | ||||
|           firstName: updatedUser.firstName, | ||||
|           lastName: updatedUser.lastName, | ||||
|           isAdmin: updatedUser.isAdmin, | ||||
|           email: updatedUser.email, | ||||
|         }, | ||||
|         success: 'Update user success' | ||||
|       }, | ||||
|       headers: { | ||||
|         'Set-Cookie': cookie.serialize('session', JSON.stringify( | ||||
|           { | ||||
|             id: updatedUser.id, | ||||
|             accessToken: locals.user.accessToken, | ||||
|             firstName: updatedUser.firstName, | ||||
|             lastName: updatedUser.lastName, | ||||
|             isAdmin: updatedUser.isAdmin, | ||||
|             email: updatedUser.email, | ||||
|           }), { | ||||
|           path: '/', | ||||
|           httpOnly: true, | ||||
|           sameSite: 'strict', | ||||
|           maxAge: 60 * 60 * 24 * 30, | ||||
|         }) | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|  | ||||
|  | ||||
|   return { | ||||
|     status: 400, | ||||
|     body: { | ||||
|       error: 'Cannot get access token from cookies' | ||||
|     } | ||||
|   } | ||||
| } | ||||
							
								
								
									
										39
									
								
								web/src/routes/auth/register/index.svelte
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,39 @@ | ||||
| <script context="module" lang="ts"> | ||||
| 	import type { Load } from '@sveltejs/kit'; | ||||
| 	import { serverEndpoint } from '$lib/constants'; | ||||
|  | ||||
| 	export const load: Load = async ({ session, fetch }) => { | ||||
| 		const res = await fetch(`${serverEndpoint}/user/count`); | ||||
| 		const { userCount } = await res.json(); | ||||
|  | ||||
| 		if (userCount != 0) { | ||||
| 			// Admin has been registered, redirect to login | ||||
|  | ||||
| 			if (!session.user) { | ||||
| 				return { | ||||
| 					status: 302, | ||||
| 					redirect: '/auth/login', | ||||
| 				}; | ||||
| 			} else { | ||||
| 				return { | ||||
| 					status: 302, | ||||
| 					redirect: '/dashboard', | ||||
| 				}; | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		return {}; | ||||
| 	}; | ||||
| </script> | ||||
|  | ||||
| <script lang="ts"> | ||||
| 	import AdminRegistrationForm from '$lib/components/forms/admin-registration-form.svelte'; | ||||
| </script> | ||||
|  | ||||
| <svelte:head> | ||||
| 	<title>Immich - Admin Registration</title> | ||||
| </svelte:head> | ||||
|  | ||||
| <section class="h-screen w-screen flex place-items-center place-content-center"> | ||||
| 	<AdminRegistrationForm /> | ||||
| </section> | ||||
							
								
								
									
										43
									
								
								web/src/routes/auth/register/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,43 @@ | ||||
| import type { RequestHandler } from '@sveltejs/kit'; | ||||
| import { serverEndpoint } from '$lib/constants'; | ||||
|  | ||||
| export const post: RequestHandler = async ({ request }) => { | ||||
|   const form = await request.formData(); | ||||
|  | ||||
|   const email = form.get('email') | ||||
|   const password = form.get('password') | ||||
|   const firstName = form.get('firstName') | ||||
|   const lastName = form.get('lastName') | ||||
|  | ||||
|   const payload = { | ||||
|     email, | ||||
|     password, | ||||
|     firstName, | ||||
|     lastName, | ||||
|   } | ||||
|  | ||||
|   const res = await fetch(`${serverEndpoint}/auth/admin-sign-up`, { | ||||
|     method: 'POST', | ||||
|     headers: { | ||||
|       'Content-Type': 'application/json' | ||||
|     }, | ||||
|     body: JSON.stringify(payload), | ||||
|   }) | ||||
|  | ||||
|   if (res.status === 201) { | ||||
|     return { | ||||
|       status: 201, | ||||
|       body: { | ||||
|         success: 'Succesfully create admin account' | ||||
|       } | ||||
|     } | ||||
|   } else { | ||||
|     return { | ||||
|       status: 400, | ||||
|       body: { | ||||
|         error: await res.json() | ||||
|       } | ||||
|     } | ||||
|  | ||||
|   } | ||||
| } | ||||
							
								
								
									
										62
									
								
								web/src/routes/index.svelte
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,62 @@ | ||||
| <script context="module" lang="ts"> | ||||
| 	export const prerender = false; | ||||
| 	import type { Load } from '@sveltejs/kit'; | ||||
|  | ||||
| 	export const load: Load = async ({ session, fetch }) => { | ||||
| 		const res = await fetch(`${serverEndpoint}/user/count`); | ||||
| 		const { userCount } = await res.json(); | ||||
|  | ||||
| 		if (!session.user) { | ||||
| 			// Check if admin exist to wherether navigating to login or registration | ||||
| 			if (userCount != 0) { | ||||
| 				return { | ||||
| 					status: 200, | ||||
| 					props: { | ||||
| 						isAdminUserExist: true, | ||||
| 					}, | ||||
| 				}; | ||||
| 			} else { | ||||
| 				return { | ||||
| 					status: 200, | ||||
| 					props: { | ||||
| 						isAdminUserExist: false, | ||||
| 					}, | ||||
| 				}; | ||||
| 			} | ||||
| 		} else { | ||||
| 			return { | ||||
| 				status: 302, | ||||
| 				redirect: '/photos', | ||||
| 			}; | ||||
| 		} | ||||
| 	}; | ||||
| </script> | ||||
|  | ||||
| <script lang="ts"> | ||||
| 	import { serverEndpoint } from '$lib/constants'; | ||||
| 	import { goto } from '$app/navigation'; | ||||
|  | ||||
| 	export let isAdminUserExist: boolean; | ||||
|  | ||||
| 	async function onGettingStartedClicked() { | ||||
| 		isAdminUserExist ? goto('/auth/login') : goto('/auth/register'); | ||||
| 	} | ||||
| </script> | ||||
|  | ||||
| <svelte:head> | ||||
| 	<title>Immich - Welcome 🎉</title> | ||||
| 	<meta name="description" content="Immich Web Interface" /> | ||||
| </svelte:head> | ||||
|  | ||||
| <section class="h-screen w-screen flex place-items-center place-content-center"> | ||||
| 	<div class="flex flex-col place-items-center gap-8 text-center max-w-[350px]"> | ||||
| 		<div class="flex place-items-center place-content-center "> | ||||
| 			<img class="text-center" src="immich-logo.svg" height="200" width="200" alt="immich-logo" /> | ||||
| 		</div> | ||||
| 		<h1 class="text-4xl text-immich-primary font-bold font-immich-title">Welcome to Immich Web</h1> | ||||
| 		<button | ||||
| 			class="border px-4 py-2 rounded-md bg-immich-primary hover:bg-immich-primary/75 text-white font-bold w-[200px]" | ||||
| 			on:click={onGettingStartedClicked}>Getting Started</button | ||||
| 		> | ||||
| 	</div> | ||||
| </section> | ||||
							
								
								
									
										75
									
								
								web/src/routes/photos/index.svelte
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,75 @@ | ||||
| <script context="module" lang="ts"> | ||||
| 	import type { Load } from '@sveltejs/kit'; | ||||
|  | ||||
| 	export const load: Load = ({ session }) => { | ||||
| 		if (!session.user) { | ||||
| 			return { | ||||
| 				status: 302, | ||||
| 				redirect: '/auth/login', | ||||
| 			}; | ||||
| 		} | ||||
|  | ||||
| 		return { | ||||
| 			status: 200, | ||||
| 			props: { | ||||
| 				user: session.user, | ||||
| 			}, | ||||
| 		}; | ||||
| 	}; | ||||
| </script> | ||||
|  | ||||
| <script lang="ts"> | ||||
| 	import type { ImmichUser } from '$lib/models/immich-user'; | ||||
|  | ||||
| 	import NavigationBar from '../../lib/components/shared/navigation-bar.svelte'; | ||||
| 	import SideBarButton from '$lib/components/shared/side-bar-button.svelte'; | ||||
| 	import Magnify from 'svelte-material-icons/Magnify.svelte'; | ||||
| 	import ImageOutline from 'svelte-material-icons/ImageOutline.svelte'; | ||||
| 	import { AppSideBarSelection } from '$lib/models/admin-sidebar-selection'; | ||||
| 	import { onMount } from 'svelte'; | ||||
|  | ||||
| 	export let user: ImmichUser; | ||||
| 	let selectedAction: AppSideBarSelection; | ||||
|  | ||||
| 	const onButtonClicked = (buttonType: CustomEvent) => { | ||||
| 		selectedAction = buttonType.detail['actionType'] as AppSideBarSelection; | ||||
| 	}; | ||||
|  | ||||
| 	onMount(() => { | ||||
| 		selectedAction = AppSideBarSelection.PHOTOS; | ||||
| 	}); | ||||
| </script> | ||||
|  | ||||
| <svelte:head> | ||||
| 	<title>Immich - Photos</title> | ||||
| </svelte:head> | ||||
|  | ||||
| <section> | ||||
| 	<NavigationBar {user} /> | ||||
| </section> | ||||
|  | ||||
| <section class="grid grid-cols-[250px_auto] relative pt-[72px] h-screen"> | ||||
| 	<section id="admin-sidebar" class="flex flex-col gap-4 pt-8 pr-6"> | ||||
| 		<SideBarButton | ||||
| 			title="Photos" | ||||
| 			logo={ImageOutline} | ||||
| 			actionType={AppSideBarSelection.PHOTOS} | ||||
| 			isSelected={selectedAction === AppSideBarSelection.PHOTOS} | ||||
| 			on:selected={onButtonClicked} | ||||
| 		/> | ||||
|  | ||||
| 		<SideBarButton | ||||
| 			title="Explore" | ||||
| 			logo={Magnify} | ||||
| 			actionType={AppSideBarSelection.EXPLORE} | ||||
| 			isSelected={selectedAction === AppSideBarSelection.EXPLORE} | ||||
| 			on:selected={onButtonClicked} | ||||
| 		/> | ||||
| 	</section> | ||||
|  | ||||
| 	<section class="overflow-y-auto relative"> | ||||
| 		<section id="setting-content" class="relative pt-[85px]"> | ||||
| 			<section class="pt-4">Coming soon</section> | ||||
| 		</section> | ||||
| 	</section> | ||||
| </section> | ||||
							
								
								
									
										
											BIN
										
									
								
								web/static/favicon.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 46 KiB | 
							
								
								
									
										
											BIN
										
									
								
								web/static/immich-logo-no-outline.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 144 KiB | 
							
								
								
									
										98
									
								
								web/static/immich-logo.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,98 @@ | ||||
| <?xml version="1.0" encoding="utf-8"?> | ||||
| <!-- Generator: Adobe Illustrator 26.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0)  --> | ||||
| <svg version="1.1" id="svg2781" xmlns:svg="http://www.w3.org/2000/svg" | ||||
| 	 xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 564.2 553.5" | ||||
| 	 style="enable-background:new 0 0 564.2 553.5;" xml:space="preserve"> | ||||
| <style type="text/css"> | ||||
| 	.st0{fill:#4081EF;stroke:#512D8C;stroke-miterlimit:10;} | ||||
| 	.st1{fill:#31A452;stroke:#512D8C;stroke-miterlimit:10;} | ||||
| 	.st2{fill:#DE7FB3;stroke:#512D8C;stroke-miterlimit:10;} | ||||
| 	.st3{fill:#FFB800;stroke:#512D8C;stroke-miterlimit:10;} | ||||
| 	.st4{fill:#E64132;stroke:#512D8C;stroke-miterlimit:10;} | ||||
| 	.st5{fill:#F2F5FB;stroke:#512D8C;stroke-miterlimit:10;} | ||||
| </style> | ||||
| <path class="st0" d="M210.5,549.6c-2.2-0.2-5.5-1-9.7-2.2c-52.4-15.7-99-46.5-133.8-88.5c-8.8-10.7-17.2-22.4-19.4-27.5 | ||||
| 	c-8.1-18.1-6.3-38.7,4.8-55.4c5-7.5,13.2-15,20.5-18.7c1.2-0.6,54.1-20,55.8-20.4c0.5-0.1,0.5,0.2-0.3,2.1c-0.7,1.7-1,3.1-1.1,5.5 | ||||
| 	l-0.1,3.2l2.8,5.8c8.7,17.9,19.2,32.7,33.2,46.4c6.3,6.2,7.8,7.6,13.8,12.3c22.7,18.1,52,30.7,79.9,34.3c2.5,0.3,5,0.8,5.7,1 | ||||
| 	c2.8,0.9,7.7-0.8,11-3.7l1.8-1.6l-0.2,4.8c-0.1,2.7-0.6,15.4-1,28.3c-0.6,20.3-0.8,24-1.5,27.5c-3.9,20.7-18.6,37.5-38.4,44.1 | ||||
| 	c-4.6,1.5-8,2.2-13.1,2.7C216.6,550.1,215.3,550,210.5,549.6z"/> | ||||
| <path class="st1" d="M339.8,549.4c-4-0.4-9.4-1.6-13.2-2.9c-3.4-1.2-10-4.4-12.5-6.1c-10.9-7.4-19-17.9-23.1-30 | ||||
| 	c-2.2-6.7-2.3-7.5-3.3-36.9c-0.5-14.9-0.9-27.9-0.9-28.9l0-1.9l2.3,1.8c2.6,2,6.6,3.4,8.5,3.1c0.6-0.1,3-0.5,5.3-0.8 | ||||
| 	c37.7-5.3,71.2-22.2,97.4-49.1c12.2-12.5,21.4-25.5,29.9-42.4l3.5-7l0-3.6c0-3.1-0.1-3.8-1-5.7c-0.5-1.2-0.9-2.1-0.9-2.2 | ||||
| 	c0.2-0.2,55.3,20.1,56.9,20.9c2.6,1.3,6.6,4.1,9.9,7c9.2,7.7,16.1,19.4,18.8,31.8c0.7,3.1,0.8,4.8,0.8,11.3c0,8.6-0.5,11.7-2.9,18.7 | ||||
| 	c-1.7,5-2.9,7.2-7.1,13.1c-7.6,11-15.3,20.5-25.2,31.2c-32.8,35.4-76.5,62.5-123.4,76.3C351.6,549.6,347.2,550.1,339.8,549.4z"/> | ||||
| <path class="st2" d="M255.6,438c-25.9-4.2-50.7-14.9-71.7-31c-5.2-4-8.7-7.1-14.1-12.4c-12.7-12.5-21.9-24.9-30.5-41.4 | ||||
| 	c-2.3-4.4-2.4-4.7-2.4-7.1c0-8.8,8.5-15.2,16.9-12.7c5.6,1.7,9.6,6.8,9.7,12.2c0,2.6-0.8,4.6-2.6,6.2c-1.2,1.1-3.2,1.9-4.6,1.9 | ||||
| 	c-1.2,0-3.3-0.8-4.3-1.6c-2.1-1.8-2-1,0.4,3.2c19.3,33.8,52.3,59.1,90,69.1c5.7,1.5,11.5,2.7,11.8,2.4c0.1-0.1-0.4-0.8-1.3-1.6 | ||||
| 	c-5.1-4.5-2.3-11.7,5-12.8c5.4-0.8,11.4,2.7,13.9,8c0.8,1.7,1,2.5,1,5.3s-0.1,3.5-1,5.3c-2,4.3-6.8,7.9-10.3,7.8 | ||||
| 	C260.6,438.7,257.9,438.3,255.6,438z"/> | ||||
| <path class="st0" d="M297.6,438.2c-3.4-1.3-6.4-4.3-7.8-8.1c-1.1-2.9-0.9-7.3,0.5-10.2c2.6-5.3,8.7-8.5,14.4-7.5 | ||||
| 	c2.9,0.5,4.7,1.9,6,4.3c0.8,1.6,1,2.2,0.8,3.6c-0.3,2.2-0.9,3.3-2.7,4.8c-0.8,0.7-1.4,1.4-1.3,1.5c0.5,0.5,13.4-2.7,21.3-5.4 | ||||
| 	c33.6-11.3,62.5-35.1,80.4-66.1c2.5-4.4,2.6-5,0.5-3.2c-2.8,2.4-7,1.9-9.6-1c-4-4.6-0.7-13.8,6.1-16.9c2-0.9,2.7-1,5.5-1 | ||||
| 	c2.9,0,3.5,0.1,5.6,1.1c4.4,2.1,7.4,6.4,7.8,11c0.2,2.2,0.1,2.3-2.2,6.9c-23,45.9-67,78.1-117.2,85.9 | ||||
| 	C300.2,438.8,299.4,438.9,297.6,438.2z"/> | ||||
| <path class="st1" d="M211.1,398.5c-4.7-0.9-8.7-2.7-12.9-5.9c-10.8-8.1-13.5-22.3-6.6-33.7c0.7-1.2,1.1-2.2,1-2.4 | ||||
| 	c-0.2-0.2-1.2-0.6-2.3-1.1c-7.6-3-13-10.6-13.5-19.1c-0.5-7.4,3.1-15,9-19.4c1-0.7,2.2-1.5,2.6-1.8c0.8-0.4,68.9-22.7,69.4-22.7 | ||||
| 	c0.2,0,0.7,0.7,1.2,1.5c0.5,0.8,1.6,2.3,2.4,3.3c1.2,1.4,1.5,1.9,1.2,2.3c-0.2,0.3-6.9,9.5-14.8,20.5 | ||||
| 	c-15.9,21.9-15.5,21.3-13.4,23.4c1.3,1.3,2.9,1.4,4.4,0.3c0.6-0.4,7.5-9.7,15.5-20.7c11.2-15.4,14.6-19.9,15-19.7 | ||||
| 	c0.9,0.4,5.5,1.9,6.6,2.1l1,0.2l0,35.3c0,39.7,0,38.8-2.5,44c-2.6,5.3-7.2,9.3-12.7,11.2c-3.7,1.3-6.8,1.6-10.2,1 | ||||
| 	c-5.5-0.9-9.8-3.2-13.7-7.4l-2.2-2.4l-0.6,0.9c-3,4.3-8.6,8.1-14,9.5C218.2,398.6,213.2,398.9,211.1,398.5z"/> | ||||
| <path class="st3" d="M342.9,398.5c-5.5-0.9-9.9-3.2-14.3-7.6l-3.2-3.2l-0.7,1c-2.3,3.3-6.8,6.5-11.1,7.9c-3.7,1.2-9.2,1.4-12.6,0.3 | ||||
| 	c-7.1-2.1-12.7-7.4-15.2-14.3l-0.9-2.6v-37.1v-37.1l1.8-0.4c1-0.2,2.7-0.8,3.9-1.2c1.1-0.5,2.1-0.8,2.2-0.7c0.1,0.1,6.5,9,14.4,19.9 | ||||
| 	c7.8,10.9,14.7,20.1,15.2,20.5c2.2,1.9,5.4,0.4,5.4-2.6c0-1.4-1-2.9-13.8-20.5c-7.6-10.5-14.2-19.6-14.7-20.4l-0.9-1.3l1.4-1.7 | ||||
| 	c0.8-0.9,1.9-2.5,2.5-3.4l1-1.6l34.4,11.2c18.9,6.2,35.1,11.6,35.9,12.1c6.8,4,11.1,11.3,11.1,19.1c0,4.1-0.5,6.4-2.4,10.2 | ||||
| 	c-2,4.1-5.5,7.6-9.6,9.7c-1.6,0.8-3.2,1.5-3.4,1.5c-1,0-0.9,0.7,0.3,2.6c2.8,4.3,4,8.5,3.9,13.7c0,8.1-3.7,15.2-10.6,20.3 | ||||
| 	C356.4,397.6,349.5,399.5,342.9,398.5z"/> | ||||
| <path class="st2" d="M53.9,341.9c-0.5-0.1-2.3-0.4-3.9-0.7c-15.6-2.6-30.4-12.6-38.8-26.2c-3.5-5.7-6.4-13.2-7.8-19.9 | ||||
| 	c-1.2-6.1-0.8-28.1,0.8-43.1c4.5-43,19-84.3,42.2-120.7c6.5-10.2,14.9-21.5,18.2-24.6c17.8-16.6,43.1-20.5,64.8-10 | ||||
| 	c4.3,2.1,8.8,5.1,12.7,8.6c2.8,2.4,5.8,6.1,20.9,25.5c9.7,12.5,17.8,22.8,17.9,23c0.2,0.2-0.9,0.4-3.2,0.4c-2.5,0-4.1,0.2-5.7,0.7 | ||||
| 	c-2.1,0.7-2.6,1.1-7.9,6.3c-8.2,8.1-14.4,15.3-20.3,23.9c-15.5,22.2-25.4,47.7-28.8,74.8c-2.2,16.9-1.6,37.5,1.6,52.3 | ||||
| 	c0.3,1.4,0.5,2.8,0.4,3c-0.1,0.2,0.2,1.3,0.8,2.4c1.1,2.4,4.3,5.7,6.5,6.8l1.5,0.8l-1.2,0.4c-0.7,0.2-13.1,3.8-27.6,8 | ||||
| 	c-16.4,4.7-27.7,7.8-29.8,8.1C64.1,342.1,56.1,342.3,53.9,341.9z"/> | ||||
| <path class="st3" d="M494.7,341.7c-2.1-0.3-33.8-9.1-56.5-15.8l-2.5-0.7l1.6-0.8c3.4-1.7,7.2-6.6,7.3-9.6c0-0.7,0.4-3.3,0.8-5.8 | ||||
| 	c3.9-22.7,3.1-46.1-2.5-68.4c-6.4-25.5-18.6-49.2-35.8-69.1c-4.6-5.3-14.8-15.4-16.4-16.1c-2.4-1.1-5.1-1.6-8-1.4l-2.7,0.2l1.2-1.5 | ||||
| 	c0.7-0.8,8.5-10.8,17.5-22.3c8.9-11.5,17.2-21.8,18.5-23.1c2.6-2.7,7-6.2,10.3-8.2c19.3-11.6,43-11.1,61.6,1.2 | ||||
| 	c5.4,3.6,8.2,6.2,12.3,11.7c26.4,34.5,44,73.7,52.3,116.2c3.4,17.6,4.9,33.3,5,52.4c0,13-0.2,14.8-2.5,21.8 | ||||
| 	C547.8,328.6,521.7,345.2,494.7,341.7z"/> | ||||
| <path class="st4" d="M133.9,318.5c-2-0.5-4.6-1.9-6-3.3c-2.5-2.4-3.1-3.5-3.7-7.3c-4.4-27.3-2.2-54,6.7-79.3 | ||||
| 	c5.3-15.1,13.5-30.5,23-43.1c5.8-7.8,16.6-19.5,19-20.7c4.7-2.4,11.3-1.2,15.2,2.7c5.4,5.4,5.2,13.9-0.3,19.1 | ||||
| 	c-4.3,4-9.4,4.4-12.6,0.9c-1.7-1.9-2.2-3.9-1.7-6.4c0.2-1.1,0.3-2,0.2-2.2c-0.3-0.3-3.6,3.3-8.3,9.1c-17.6,21.8-28.5,48-31.9,76.5 | ||||
| 	c-1.1,9.3-1,26.4,0.1,34.6c0.3,1.8,0.8,1.9,1.4,0.1c0.9-2.6,4-4.7,6.8-4.7c3,0,5.9,2.2,7.5,5.7c0.6,1.3,0.8,2.3,0.8,5.2 | ||||
| 	c0,3.3-0.1,3.8-1.1,5.7c-1.4,2.7-4.6,5.7-7.1,6.6C139.4,318.6,135.8,318.9,133.9,318.5z"/> | ||||
| <path class="st1" d="M422.6,318.5c-3.7-0.6-7.7-3.6-9.4-7.1c-3.8-7.5,0.1-16.9,6.9-16.9c3.1,0,5.8,2,6.9,5.2 | ||||
| 	c0.4,1.2,0.5,1.3,0.7,0.7c1.3-3.7,1.7-26.4,0.6-35.7c-3.6-29.6-14.5-55.3-33-77.9c-5.5-6.7-8.4-9.4-7.1-6.6c0.7,1.4,0.5,4.3-0.3,5.9 | ||||
| 	c-0.9,1.7-3.2,3.5-5,3.8c-3.2,0.6-7.9-1.6-10.2-4.8c-6.5-8.8-0.5-21.2,10.4-21.4c4.6-0.1,5.2,0.3,11.2,6.4 | ||||
| 	c12.1,12.3,21.1,24.9,28.8,40.3c13.2,26.3,18.6,54.9,16.1,84.5c-0.5,5.6-2,15.7-2.6,17.1c-1.3,2.8-4.8,5.5-8.4,6.5 | ||||
| 	C425.9,318.9,425.1,318.9,422.6,318.5z"/> | ||||
| <path class="st0" d="M178.2,307.2c-6-1.3-12.2-6.2-14.9-11.7c-3.4-7-3.1-15.1,0.9-21.6c0.7-1.2,1.2-2.3,1.1-2.4 | ||||
| 	c-0.1-0.1-1.1-0.6-2.1-1c-3.9-1.5-8.1-4.8-10.7-8.3c-4.6-6.2-6.1-14.6-3.9-22.1c2.9-10.3,9.4-16.8,19.1-19.3c2.8-0.7,9-0.8,11.7,0 | ||||
| 	c1.1,0.3,2.2,0.5,2.4,0.5c0.2,0,0.3-0.7,0.3-1.5c0-2.9,0.8-5.8,2.4-9.2c5.2-10.8,18.1-15.5,29-10.5c2.7,1.2,6.2,3.8,7.8,5.8 | ||||
| 	c0.7,0.8,10.3,14,21.5,29.4l20.3,27.9l-1.5,1.8c-0.8,1-1.9,2.6-2.5,3.5c-0.6,1-1.2,1.7-1.5,1.6c-4.5-1.7-46.7-15-47.7-15 | ||||
| 	c-1.9,0-3.1,1.3-3.1,3.2c0,1,0.2,1.7,0.8,2.3c0.6,0.6,7.8,3.1,24.5,8.5l23.7,7.7l-0.1,4.3l-0.1,4.3L223,295.9 | ||||
| 	c-18,5.9-33.9,10.9-35.2,11.2C184.7,307.8,181.2,307.8,178.2,307.2z"/> | ||||
| <path class="st4" d="M372.5,306.8c-1.8-0.5-17.5-5.6-35-11.3l-31.8-10.4l1-4.3v-4.3l22.6-7.7c15-4.9,24-8,24.6-8.5 | ||||
| 	c0.7-0.6,0.9-1.1,0.9-2.2c0-2-1.2-3.3-3.1-3.3c-0.9,0-10.5,2.9-24.7,7.5c-12.8,4.1-23.4,7.5-23.6,7.5c-0.1,0-0.7-0.8-1.3-1.9 | ||||
| 	c-0.6-1-1.6-2.5-2.2-3.2c-0.7-0.7-1.2-1.5-1.2-1.6c0-0.2,9.6-13.5,21.4-29.6c18.9-26,21.6-29.6,23.6-31.1c5.7-4.4,13.1-5.8,19.7-3.9 | ||||
| 	c9,2.7,16.1,11.6,16.1,20.3c0,2.3-0.1,2.3,3.1,1.5c4.7-1.1,11.5-0.5,16,1.5c4.6,2,9,6,11.5,10.2c2.1,3.6,3.9,9.4,4.2,13.2 | ||||
| 	c0.3,5.2-1.1,10.7-4,15.3c-2.6,4.1-7.8,8.3-12.1,9.8c-0.9,0.3-1.7,0.8-1.7,1c0,0.2,0.4,1,0.9,1.7c2.4,3.6,3.6,7.7,3.5,12.7 | ||||
| 	c0,5.8-2.1,10.7-6.4,15.1c-4,4.1-8.9,6.3-14.9,6.5C376.3,307.7,375.3,307.6,372.5,306.8z"/> | ||||
| <path class="st5" d="M276.2,298.9c-6.1-1.6-11.4-6.8-13.2-12.9c-0.7-2.4-0.7-7.5,0-9.9c1.7-5.8,6.6-10.8,12.3-12.5 | ||||
| 	c2.7-0.8,7.2-0.9,10-0.2c6.2,1.6,11.6,7.1,13.2,13.3c1.6,6-0.3,12.6-5,17.3C288.9,298.6,282.2,300.5,276.2,298.9z"/> | ||||
| <path class="st2" d="M248.3,229.8c-13.3-18.3-21.2-29.6-22-31.1c-1.4-3-1.9-5.5-1.9-9.4c0-14.1,13.1-24.4,27.1-21.4 | ||||
| 	c1.4,0.3,2.6,0.5,2.7,0.5s0.3-1.3,0.4-2.8c0.8-10.7,8.4-19.6,18.9-22.4c3.9-1,10.6-1,14.5,0c8.9,2.3,15.9,9.3,18.2,18.2 | ||||
| 	c0.4,1.5,0.7,3.7,0.7,4.9c0,1.2,0.1,2.1,0.3,2.1s1.5-0.3,3-0.6c7.4-1.6,15.2,0.7,20.5,6c4.3,4.3,6.6,9.6,6.6,15.6 | ||||
| 	c0,4-0.6,6.5-2.4,10c-0.6,1.2-10.4,15-21.7,30.7c-17.8,24.5-20.8,28.5-21.4,28.3c-0.4-0.1-1.9-0.6-3.4-1.1c-1.5-0.5-2.9-0.9-3.3-0.9 | ||||
| 	c-0.7,0-0.7-0.8-0.3-25.5v-25.5l-1.4-0.9c-1-1.1-2.5-1.5-3.8-0.9c-2,0.8-2-0.5-1.8,27.2v25.8h-1.2c-0.5-0.2-2.4,0.3-4,0.9 | ||||
| 	s-3.1,1.1-3.2,1.1C269.2,258.5,259.8,245.6,248.3,229.8z"/> | ||||
| <path class="st3" d="M210.9,164.8c-4.1-0.9-7.7-3.6-9.6-7.4c-1.4-2.8-1.7-7.3-0.5-10.3c1.7-4.5,3.9-6.1,15.6-11.2 | ||||
| 	c15.8-7,31.4-11.1,49.2-12.9c7.3-0.8,23.2-0.8,30.6,0c17.4,1.8,33.3,6,49.1,13c7.3,3.2,12.5,6.1,13.6,7.5c4.3,5.6,3.8,12.7-1.1,17.6 | ||||
| 	c-5.1,5.1-12.9,5.4-18.1,0.7c-2-1.8-3-3.5-3.4-5.6c-0.7-4,2.9-8.1,7.3-8.2c1.4,0,1.5-0.1,1.1-0.5c-0.3-0.3-2.2-1.2-4.3-2.1 | ||||
| 	c-33.2-14.5-70.5-16.4-105-5.4c-7.5,2.4-19,7.2-18.6,7.7c0.1,0.2,0.8,0.3,1.6,0.3c5.6,0,9.1,6.2,6.1,10.8 | ||||
| 	C221.6,163.3,215.9,165.9,210.9,164.8z"/> | ||||
| <path class="st4" d="M174.7,123.4c-8.9-13.1-16.8-25.1-17.5-26.6c-1.6-3.3-3.6-9.2-4.4-13c-2.6-12.5-0.9-25.8,5-37.5 | ||||
| 	c4.2-8.3,11.2-16.3,18.6-21.3c5-3.4,6.1-3.9,12.8-6.3c23.1-8.2,47.2-13.1,73.4-15c7.5-0.6,28.5-0.6,36.3,0 | ||||
| 	c25.5,1.8,50.6,6.9,73,14.8c6.4,2.2,8.2,3.1,13.1,6.5c9.8,6.6,18.1,17.5,22,29.2c2.2,6.5,2.7,10,2.7,17.9c0,7.9-0.5,11.3-2.7,17.9 | ||||
| 	c-2.3,6.8-3.7,9.1-20.3,33.6l-16.1,23.8l-0.4-2.2c-0.2-1.2-0.9-3-1.4-4c-1-1.8-4.4-5.6-4.7-5.2c-0.1,0.1-1.2-0.4-2.4-1.1 | ||||
| 	c-9.1-5.2-21.9-10.5-33.2-13.9c-37-11-77.2-8.8-113,6.1c-4.9,2.1-17.7,8.4-19.2,9.5c-2.2,1.6-5.1,6.8-5.1,9c0,0.4-0.1,1-0.3,1.2 | ||||
| 	C191,147,184.7,138,174.7,123.4z"/> | ||||
| </svg> | ||||
| After Width: | Height: | Size: 9.7 KiB | 
							
								
								
									
										3
									
								
								web/static/robots.txt
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,3 @@ | ||||
| # https://www.robotstxt.org/robotstxt.html | ||||
| User-agent: * | ||||
| Disallow: | ||||
							
								
								
									
										
											BIN
										
									
								
								web/static/svelte-welcome.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 352 KiB | 
							
								
								
									
										
											BIN
										
									
								
								web/static/svelte-welcome.webp
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 113 KiB | 
							
								
								
									
										18
									
								
								web/svelte.config.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,18 @@ | ||||
| import preprocess from 'svelte-preprocess'; | ||||
| import adapter from '@sveltejs/adapter-node'; | ||||
|  | ||||
| /** @type {import('@sveltejs/kit').Config} */ | ||||
| const config = { | ||||
| 	// Consult https://github.com/sveltejs/svelte-preprocess | ||||
| 	// for more information about preprocessors | ||||
| 	preprocess: preprocess(), | ||||
|  | ||||
| 	kit: { | ||||
| 		adapter: adapter({ out: 'build' }), | ||||
| 		methodOverride: { | ||||
| 			allowed: ['PATCH', 'DELETE'], | ||||
| 		}, | ||||
| 	}, | ||||
| }; | ||||
|  | ||||
| export default config; | ||||
							
								
								
									
										15
									
								
								web/tailwind.config.cjs
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,15 @@ | ||||
| module.exports = { | ||||
| 	content: ['./src/**/*.{html,js,svelte,ts}'], | ||||
| 	theme: { | ||||
| 		extend: { | ||||
| 			colors: { | ||||
| 				'immich-primary': '#4250af', | ||||
| 				'immich-bg': '#f6f8fe', | ||||
| 			}, | ||||
| 			fontFamily: { | ||||
| 				'immich-title': ['Snowburst One', 'cursive'], | ||||
| 			}, | ||||
| 		}, | ||||
| 	}, | ||||
| 	plugins: [], | ||||
| }; | ||||
							
								
								
									
										20
									
								
								web/tsconfig.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,20 @@ | ||||
| { | ||||
|   "extends": "./.svelte-kit/tsconfig.json", | ||||
|   "compilerOptions": { | ||||
|     "allowJs": true, | ||||
|     "checkJs": true, | ||||
|     "esModuleInterop": true, | ||||
|     "forceConsistentCasingInFileNames": true, | ||||
|     "lib": [ | ||||
|       "es2020", | ||||
|       "DOM" | ||||
|     ], | ||||
|     "moduleResolution": "node", | ||||
|     "module": "es2020", | ||||
|     "resolveJsonModule": true, | ||||
|     "skipLibCheck": true, | ||||
|     "sourceMap": true, | ||||
|     "strict": true, | ||||
|     "target": "es2020", | ||||
|   } | ||||
| } | ||||