Add web interface with admin functionality (#167)
29
.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:
|
||||
@ -62,3 +61,31 @@ jobs:
|
||||
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
|
||||
|
43
.github/workflows/build_push_server_release.yml
vendored
@ -15,7 +15,7 @@ jobs:
|
||||
ref: "main"
|
||||
fetch-depth: 0
|
||||
|
||||
- name: 'Get Previous tag'
|
||||
- name: "Get Previous tag"
|
||||
id: previoustag
|
||||
uses: "WyriHaximus/github-action-get-previous-tag@v1"
|
||||
with:
|
||||
@ -53,7 +53,7 @@ jobs:
|
||||
ref: "main"
|
||||
fetch-depth: 0
|
||||
|
||||
- name: 'Get Previous tag'
|
||||
- name: "Get Previous tag"
|
||||
id: previoustag
|
||||
uses: "WyriHaximus/github-action-get-previous-tag@v1"
|
||||
with:
|
||||
@ -81,3 +81,42 @@ jobs:
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: |
|
||||
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=
|
||||
|
||||
|
||||
|
||||
|
||||
###################################################################################
|
||||
# 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';
|
||||
|
||||
|
@ -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",
|
||||
}
|
||||
}
|