mirror of
https://github.com/LogicLabs-OU/OpenArchiver.git
synced 2026-04-06 00:31:57 +02:00
Admin user login support
This commit is contained in:
@@ -20,3 +20,9 @@ MEILI_HOST=http://meilisearch:7700
|
||||
# JWT
|
||||
JWT_SECRET="a-very-secret-key"
|
||||
JWT_EXPIRES_IN="7d"
|
||||
|
||||
|
||||
|
||||
# Admin users
|
||||
ADMIN_EMAIL=admin@local.com
|
||||
ADMIN_PASSWORD=a_strong_pass
|
||||
@@ -57,7 +57,7 @@ Open Archive is built on a modern, scalable, and maintainable technology stack:
|
||||
3. **Run the application:**
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
This command will build the necessary Docker images and start all the services (frontend, backend, database, etc.) in the background.
|
||||
|
||||
11
package.json
11
package.json
@@ -2,20 +2,21 @@
|
||||
"name": "open-archive",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "pnpm --filter \"./packages/*\" --parallel dev",
|
||||
"dev": "dotenv -- pnpm --filter \"./packages/*\" --parallel dev",
|
||||
"build": "pnpm --filter \"./packages/*\" --parallel build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.0.0"
|
||||
"dotenv-cli": "8.0.0",
|
||||
"typescript": "5.8.3"
|
||||
},
|
||||
"packageManager": "pnpm@10.13.1",
|
||||
"engines": {
|
||||
"node": ">=18.0.0",
|
||||
"pnpm": ">=8.0.0"
|
||||
"node": ">=22.0.0",
|
||||
"pnpm": "10.13.1"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"esbuild"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,9 +11,15 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@open-archive/types": "workspace:*",
|
||||
"express": "^5.1.0",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"bullmq": "^5.56.3",
|
||||
"dotenv": "^17.2.0"
|
||||
"dotenv": "^17.2.0",
|
||||
"express": "^5.1.0",
|
||||
"jose": "^6.0.11",
|
||||
"pg": "^8.16.3",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"sqlite3": "^5.1.7",
|
||||
"tsconfig-paths": "^4.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^5.0.3",
|
||||
|
||||
32
packages/backend/src/api/controllers/auth.controller.ts
Normal file
32
packages/backend/src/api/controllers/auth.controller.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import type { IAuthService } from '../../services/AuthService';
|
||||
|
||||
export class AuthController {
|
||||
#authService: IAuthService;
|
||||
|
||||
constructor(authService: IAuthService) {
|
||||
this.#authService = authService;
|
||||
}
|
||||
|
||||
public login = async (req: Request, res: Response): Promise<Response> => {
|
||||
const { email, password } = req.body;
|
||||
|
||||
if (!email || !password) {
|
||||
return res.status(400).json({ message: 'Email and password are required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.#authService.login(email, password);
|
||||
|
||||
if (!result) {
|
||||
return res.status(401).json({ message: 'Invalid credentials' });
|
||||
}
|
||||
|
||||
return res.status(200).json(result);
|
||||
} catch (error) {
|
||||
// In a real application, you'd want to log this error.
|
||||
console.error('Login error:', error);
|
||||
return res.status(500).json({ message: 'An internal server error occurred' });
|
||||
}
|
||||
};
|
||||
}
|
||||
39
packages/backend/src/api/middleware/requireAuth.ts
Normal file
39
packages/backend/src/api/middleware/requireAuth.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import type { IAuthService } from '../../services/AuthService';
|
||||
import type { AuthTokenPayload } from '@open-archive/types';
|
||||
|
||||
// By using module augmentation, we can add our custom 'user' property
|
||||
// to the Express Request interface in a type-safe way.
|
||||
declare global {
|
||||
namespace Express {
|
||||
export interface Request {
|
||||
user?: AuthTokenPayload;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const requireAuth = (authService: IAuthService) => {
|
||||
return async (req: Request, res: Response, next: NextFunction) => {
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return res.status(401).json({ message: 'Unauthorized: No token provided' });
|
||||
}
|
||||
|
||||
const token = authHeader.split(' ')[1];
|
||||
|
||||
try {
|
||||
const payload = await authService.verifyToken(token);
|
||||
|
||||
if (!payload) {
|
||||
return res.status(401).json({ message: 'Unauthorized: Invalid token' });
|
||||
}
|
||||
|
||||
req.user = payload;
|
||||
next();
|
||||
} catch (error) {
|
||||
console.error('Authentication error:', error);
|
||||
return res.status(500).json({ message: 'An internal server error occurred during authentication' });
|
||||
}
|
||||
};
|
||||
};
|
||||
15
packages/backend/src/api/routes/auth.routes.ts
Normal file
15
packages/backend/src/api/routes/auth.routes.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Router } from 'express';
|
||||
import type { AuthController } from '../controllers/auth.controller';
|
||||
|
||||
export const createAuthRouter = (authController: AuthController): Router => {
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* @route POST /api/v1/auth/login
|
||||
* @description Authenticates a user and returns a JWT.
|
||||
* @access Public
|
||||
*/
|
||||
router.post('/login', authController.login);
|
||||
|
||||
return router;
|
||||
};
|
||||
@@ -1,15 +1,65 @@
|
||||
import express from 'express';
|
||||
import dotenv from 'dotenv';
|
||||
import { AuthController } from './api/controllers/auth.controller';
|
||||
import { requireAuth } from './api/middleware/requireAuth';
|
||||
import { createAuthRouter } from './api/routes/auth.routes';
|
||||
import { AuthService } from './services/AuthService';
|
||||
import { AdminUserService } from './services/UserService';
|
||||
|
||||
|
||||
// Load environment variables
|
||||
dotenv.config();
|
||||
|
||||
// --- Environment Variable Validation ---
|
||||
const {
|
||||
PORT_BACKEND,
|
||||
JWT_SECRET,
|
||||
JWT_EXPIRES_IN
|
||||
} = process.env;
|
||||
|
||||
|
||||
if (!PORT_BACKEND || !JWT_SECRET || !JWT_EXPIRES_IN) {
|
||||
throw new Error('Missing required environment variables for the backend.');
|
||||
}
|
||||
|
||||
// --- Dependency Injection Setup ---
|
||||
|
||||
const userService = new AdminUserService();
|
||||
const authService = new AuthService(userService, JWT_SECRET, JWT_EXPIRES_IN);
|
||||
const authController = new AuthController(authService);
|
||||
|
||||
// --- Express App Initialization ---
|
||||
const app = express();
|
||||
const port = process.env.PORT_BACKEND || 8000;
|
||||
|
||||
// Middleware
|
||||
app.use(express.json()); // For parsing application/json
|
||||
|
||||
// --- Routes ---
|
||||
const authRouter = createAuthRouter(authController);
|
||||
app.use('/v1/auth', authRouter);
|
||||
|
||||
// Example of a protected route
|
||||
app.get('/v1/protected', requireAuth(authService), (req, res) => {
|
||||
res.json({
|
||||
message: 'You have accessed a protected route!',
|
||||
user: req.user, // The user payload is attached by the requireAuth middleware
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/', (req, res) => {
|
||||
res.send('Backend is running!');
|
||||
});
|
||||
|
||||
app.listen(port, () => {
|
||||
console.log(`Backend listening at http://localhost:${port}`);
|
||||
});
|
||||
// --- Server Start ---
|
||||
const startServer = async () => {
|
||||
try {
|
||||
app.listen(PORT_BACKEND, () => {
|
||||
console.log(`Backend listening at http://localhost:${PORT_BACKEND}`);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to start the server:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
startServer();
|
||||
|
||||
86
packages/backend/src/services/AuthService.ts
Normal file
86
packages/backend/src/services/AuthService.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { compare, hash } from 'bcryptjs';
|
||||
import type { SignJWT, jwtVerify } from 'jose';
|
||||
import type { AuthTokenPayload, User, LoginResponse } from '@open-archive/types';
|
||||
|
||||
// This interface defines the contract for a service that manages users.
|
||||
// The AuthService will depend on this abstraction, not a concrete implementation.
|
||||
export interface IUserService {
|
||||
findByEmail(email: string): Promise<User | null>;
|
||||
}
|
||||
|
||||
// This interface defines the contract for our AuthService.
|
||||
export interface IAuthService {
|
||||
verifyPassword(password: string, hash: string): Promise<boolean>;
|
||||
login(email: string, password: string): Promise<LoginResponse | null>;
|
||||
verifyToken(token: string): Promise<AuthTokenPayload | null>;
|
||||
}
|
||||
|
||||
export class AuthService implements IAuthService {
|
||||
#userService: IUserService;
|
||||
#jwtSecret: Uint8Array;
|
||||
#jwtExpiresIn: string;
|
||||
#jose: Promise<{ SignJWT: typeof SignJWT; jwtVerify: typeof jwtVerify; }>;
|
||||
|
||||
constructor(userService: IUserService, jwtSecret: string, jwtExpiresIn: string) {
|
||||
this.#userService = userService;
|
||||
this.#jwtSecret = new TextEncoder().encode(jwtSecret);
|
||||
this.#jwtExpiresIn = jwtExpiresIn;
|
||||
this.#jose = import('jose');
|
||||
}
|
||||
|
||||
#hashPassword(password: string): Promise<string> {
|
||||
return hash(password, 10);
|
||||
}
|
||||
|
||||
public verifyPassword(password: string, hash: string): Promise<boolean> {
|
||||
return compare(password, hash);
|
||||
}
|
||||
|
||||
async #generateAccessToken(payload: AuthTokenPayload): Promise<string> {
|
||||
if (!payload.sub) {
|
||||
throw new Error('JWT payload must have a subject (sub) claim.');
|
||||
}
|
||||
const { SignJWT } = await this.#jose;
|
||||
return new SignJWT(payload)
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setIssuedAt()
|
||||
.setSubject(payload.sub)
|
||||
.setExpirationTime(this.#jwtExpiresIn)
|
||||
.sign(this.#jwtSecret);
|
||||
}
|
||||
|
||||
public async login(email: string, password: string): Promise<LoginResponse | null> {
|
||||
const user = await this.#userService.findByEmail(email);
|
||||
|
||||
if (!user) {
|
||||
return null; // User not found
|
||||
}
|
||||
|
||||
const isPasswordValid = await this.verifyPassword(password, user.passwordHash);
|
||||
|
||||
if (!isPasswordValid) {
|
||||
return null; // Invalid password
|
||||
}
|
||||
|
||||
const { passwordHash, ...userWithoutPassword } = user;
|
||||
|
||||
const accessToken = await this.#generateAccessToken({
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
});
|
||||
|
||||
return { accessToken, user: userWithoutPassword };
|
||||
}
|
||||
|
||||
public async verifyToken(token: string): Promise<AuthTokenPayload | null> {
|
||||
try {
|
||||
const { jwtVerify } = await this.#jose;
|
||||
const { payload } = await jwtVerify<AuthTokenPayload>(token, this.#jwtSecret);
|
||||
return payload;
|
||||
} catch (error) {
|
||||
// Token is invalid or expired
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
0
packages/backend/src/services/DatabaseService.ts
Normal file
0
packages/backend/src/services/DatabaseService.ts
Normal file
31
packages/backend/src/services/UserService.ts
Normal file
31
packages/backend/src/services/UserService.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { hash } from 'bcryptjs';
|
||||
import type { User } from '@open-archive/types';
|
||||
import type { IUserService } from './AuthService';
|
||||
|
||||
// This is a mock implementation of the IUserService.
|
||||
// In a real application, this service would interact with a database.
|
||||
export class AdminUserService implements IUserService {
|
||||
#users: User[] = [];
|
||||
|
||||
constructor() {
|
||||
// Immediately seed the user when the service is instantiated.
|
||||
this.seed();
|
||||
}
|
||||
|
||||
// use .env admin user
|
||||
private async seed() {
|
||||
const passwordHash = await hash(process.env.ADMIN_PASSWORD as string, 10);
|
||||
this.#users.push({
|
||||
id: '1',
|
||||
email: process.env.ADMIN_EMAIL as string,
|
||||
role: 'Super Administrator',
|
||||
passwordHash: passwordHash,
|
||||
});
|
||||
}
|
||||
|
||||
public async findByEmail(email: string): Promise<User | null> {
|
||||
// In a real implementation, this would be a database query.
|
||||
const user = this.#users.find(u => u.email === email);
|
||||
return user || null;
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,9 @@
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"composite": true
|
||||
"composite": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
|
||||
16
packages/frontend/components.json
Normal file
16
packages/frontend/components.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"$schema": "https://shadcn-svelte.com/schema.json",
|
||||
"tailwind": {
|
||||
"css": "src/app.css",
|
||||
"baseColor": "slate"
|
||||
},
|
||||
"aliases": {
|
||||
"components": "$lib/components",
|
||||
"utils": "$lib/utils",
|
||||
"ui": "$lib/components/ui",
|
||||
"hooks": "$lib/hooks",
|
||||
"lib": "$lib"
|
||||
},
|
||||
"typescript": true,
|
||||
"registry": "https://shadcn-svelte.com/registry"
|
||||
}
|
||||
@@ -13,17 +13,28 @@
|
||||
"format": "prettier --write .",
|
||||
"lint": "prettier --check ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@open-archive/types": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@internationalized/date": "^3.8.2",
|
||||
"@lucide/svelte": "^0.525.0",
|
||||
"@sveltejs/adapter-auto": "^6.0.0",
|
||||
"@sveltejs/kit": "^2.16.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"bits-ui": "^2.8.10",
|
||||
"clsx": "^2.1.1",
|
||||
"dotenv": "^17.2.0",
|
||||
"prettier": "^3.4.2",
|
||||
"prettier-plugin-svelte": "^3.3.3",
|
||||
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwind-variants": "^1.0.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"tw-animate-css": "^1.3.5",
|
||||
"typescript": "^5.0.0",
|
||||
"vite": "^6.2.6"
|
||||
}
|
||||
|
||||
@@ -1 +1,121 @@
|
||||
@import 'tailwindcss';
|
||||
@import "tailwindcss";
|
||||
|
||||
@import "tw-animate-css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.129 0.042 264.695);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.129 0.042 264.695);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.129 0.042 264.695);
|
||||
--primary: oklch(0.208 0.042 265.755);
|
||||
--primary-foreground: oklch(0.984 0.003 247.858);
|
||||
--secondary: oklch(0.968 0.007 247.896);
|
||||
--secondary-foreground: oklch(0.208 0.042 265.755);
|
||||
--muted: oklch(0.968 0.007 247.896);
|
||||
--muted-foreground: oklch(0.554 0.046 257.417);
|
||||
--accent: oklch(0.968 0.007 247.896);
|
||||
--accent-foreground: oklch(0.208 0.042 265.755);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.929 0.013 255.508);
|
||||
--input: oklch(0.929 0.013 255.508);
|
||||
--ring: oklch(0.704 0.04 256.788);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.984 0.003 247.858);
|
||||
--sidebar-foreground: oklch(0.129 0.042 264.695);
|
||||
--sidebar-primary: oklch(0.208 0.042 265.755);
|
||||
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
|
||||
--sidebar-accent: oklch(0.968 0.007 247.896);
|
||||
--sidebar-accent-foreground: oklch(0.208 0.042 265.755);
|
||||
--sidebar-border: oklch(0.929 0.013 255.508);
|
||||
--sidebar-ring: oklch(0.704 0.04 256.788);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.129 0.042 264.695);
|
||||
--foreground: oklch(0.984 0.003 247.858);
|
||||
--card: oklch(0.208 0.042 265.755);
|
||||
--card-foreground: oklch(0.984 0.003 247.858);
|
||||
--popover: oklch(0.208 0.042 265.755);
|
||||
--popover-foreground: oklch(0.984 0.003 247.858);
|
||||
--primary: oklch(0.929 0.013 255.508);
|
||||
--primary-foreground: oklch(0.208 0.042 265.755);
|
||||
--secondary: oklch(0.279 0.041 260.031);
|
||||
--secondary-foreground: oklch(0.984 0.003 247.858);
|
||||
--muted: oklch(0.279 0.041 260.031);
|
||||
--muted-foreground: oklch(0.704 0.04 256.788);
|
||||
--accent: oklch(0.279 0.041 260.031);
|
||||
--accent-foreground: oklch(0.984 0.003 247.858);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.551 0.027 264.364);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.208 0.042 265.755);
|
||||
--sidebar-foreground: oklch(0.984 0.003 247.858);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
|
||||
--sidebar-accent: oklch(0.279 0.041 260.031);
|
||||
--sidebar-accent-foreground: oklch(0.984 0.003 247.858);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.551 0.027 264.364);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
32
packages/frontend/src/lib/api.ts
Normal file
32
packages/frontend/src/lib/api.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { authStore } from '$lib/stores/auth.store';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
const BASE_URL = '/api/v1'; // Using a relative URL for proxying
|
||||
|
||||
/**
|
||||
* A custom fetch wrapper to automatically handle authentication headers.
|
||||
* @param url The URL to fetch, relative to the API base.
|
||||
* @param options The standard Fetch API options.
|
||||
* @returns A Promise that resolves to the Fetch Response.
|
||||
*/
|
||||
export const api = async (url: string, options: RequestInit = {}): Promise<Response> => {
|
||||
const { accessToken } = get(authStore);
|
||||
|
||||
const defaultHeaders: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
if (accessToken) {
|
||||
defaultHeaders['Authorization'] = `Bearer ${accessToken}`;
|
||||
}
|
||||
|
||||
const mergedOptions: RequestInit = {
|
||||
...options,
|
||||
headers: {
|
||||
...defaultHeaders,
|
||||
...options.headers,
|
||||
},
|
||||
};
|
||||
|
||||
return fetch(`${BASE_URL}${url}`, mergedOptions);
|
||||
};
|
||||
80
packages/frontend/src/lib/components/ui/button/button.svelte
Normal file
80
packages/frontend/src/lib/components/ui/button/button.svelte
Normal file
@@ -0,0 +1,80 @@
|
||||
<script lang="ts" module>
|
||||
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements';
|
||||
import { type VariantProps, tv } from 'tailwind-variants';
|
||||
|
||||
export const buttonVariants = tv({
|
||||
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium outline-none transition-all focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
|
||||
destructive:
|
||||
'bg-destructive shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white',
|
||||
outline:
|
||||
'bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border',
|
||||
secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
|
||||
link: 'text-primary underline-offset-4 hover:underline'
|
||||
},
|
||||
size: {
|
||||
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
|
||||
sm: 'h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5',
|
||||
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
|
||||
icon: 'size-9'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default'
|
||||
}
|
||||
});
|
||||
|
||||
export type ButtonVariant = VariantProps<typeof buttonVariants>['variant'];
|
||||
export type ButtonSize = VariantProps<typeof buttonVariants>['size'];
|
||||
|
||||
export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
|
||||
WithElementRef<HTMLAnchorAttributes> & {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
};
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
let {
|
||||
class: className,
|
||||
variant = 'default',
|
||||
size = 'default',
|
||||
ref = $bindable(null),
|
||||
href = undefined,
|
||||
type = 'button',
|
||||
disabled,
|
||||
children,
|
||||
...restProps
|
||||
}: ButtonProps = $props();
|
||||
</script>
|
||||
|
||||
{#if href}
|
||||
<a
|
||||
bind:this={ref}
|
||||
data-slot="button"
|
||||
class={cn(buttonVariants({ variant, size }), className)}
|
||||
href={disabled ? undefined : href}
|
||||
aria-disabled={disabled}
|
||||
role={disabled ? 'link' : undefined}
|
||||
tabindex={disabled ? -1 : undefined}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</a>
|
||||
{:else}
|
||||
<button
|
||||
bind:this={ref}
|
||||
data-slot="button"
|
||||
class={cn(buttonVariants({ variant, size }), className)}
|
||||
{type}
|
||||
{disabled}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</button>
|
||||
{/if}
|
||||
17
packages/frontend/src/lib/components/ui/button/index.ts
Normal file
17
packages/frontend/src/lib/components/ui/button/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import Root, {
|
||||
type ButtonProps,
|
||||
type ButtonSize,
|
||||
type ButtonVariant,
|
||||
buttonVariants,
|
||||
} from "./button.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
type ButtonProps as Props,
|
||||
//
|
||||
Root as Button,
|
||||
buttonVariants,
|
||||
type ButtonProps,
|
||||
type ButtonSize,
|
||||
type ButtonVariant,
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="card-action"
|
||||
class={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,15 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div bind:this={ref} data-slot="card-content" class={cn("px-6", className)} {...restProps}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLParagraphElement>> = $props();
|
||||
</script>
|
||||
|
||||
<p
|
||||
bind:this={ref}
|
||||
data-slot="card-description"
|
||||
class={cn("text-muted-foreground text-sm", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</p>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="card-footer"
|
||||
class={cn("[.border-t]:pt-6 flex items-center px-6", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="card-header"
|
||||
class={cn(
|
||||
"@container/card-header has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6 grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="card-title"
|
||||
class={cn("font-semibold leading-none", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
23
packages/frontend/src/lib/components/ui/card/card.svelte
Normal file
23
packages/frontend/src/lib/components/ui/card/card.svelte
Normal file
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="card"
|
||||
class={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
25
packages/frontend/src/lib/components/ui/card/index.ts
Normal file
25
packages/frontend/src/lib/components/ui/card/index.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import Root from "./card.svelte";
|
||||
import Content from "./card-content.svelte";
|
||||
import Description from "./card-description.svelte";
|
||||
import Footer from "./card-footer.svelte";
|
||||
import Header from "./card-header.svelte";
|
||||
import Title from "./card-title.svelte";
|
||||
import Action from "./card-action.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
Content,
|
||||
Description,
|
||||
Footer,
|
||||
Header,
|
||||
Title,
|
||||
Action,
|
||||
//
|
||||
Root as Card,
|
||||
Content as CardContent,
|
||||
Description as CardDescription,
|
||||
Footer as CardFooter,
|
||||
Header as CardHeader,
|
||||
Title as CardTitle,
|
||||
Action as CardAction,
|
||||
};
|
||||
7
packages/frontend/src/lib/components/ui/input/index.ts
Normal file
7
packages/frontend/src/lib/components/ui/input/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import Root from "./input.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Input,
|
||||
};
|
||||
51
packages/frontend/src/lib/components/ui/input/input.svelte
Normal file
51
packages/frontend/src/lib/components/ui/input/input.svelte
Normal file
@@ -0,0 +1,51 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLInputAttributes, HTMLInputTypeAttribute } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
type InputType = Exclude<HTMLInputTypeAttribute, "file">;
|
||||
|
||||
type Props = WithElementRef<
|
||||
Omit<HTMLInputAttributes, "type"> &
|
||||
({ type: "file"; files?: FileList } | { type?: InputType; files?: undefined })
|
||||
>;
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
value = $bindable(),
|
||||
type,
|
||||
files = $bindable(),
|
||||
class: className,
|
||||
...restProps
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if type === "file"}
|
||||
<input
|
||||
bind:this={ref}
|
||||
data-slot="input"
|
||||
class={cn(
|
||||
"selection:bg-primary dark:bg-input/30 selection:text-primary-foreground border-input ring-offset-background placeholder:text-muted-foreground shadow-xs flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 pt-1.5 text-sm font-medium outline-none transition-[color,box-shadow] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
)}
|
||||
type="file"
|
||||
bind:files
|
||||
bind:value
|
||||
{...restProps}
|
||||
/>
|
||||
{:else}
|
||||
<input
|
||||
bind:this={ref}
|
||||
data-slot="input"
|
||||
class={cn(
|
||||
"border-input bg-background selection:bg-primary dark:bg-input/30 selection:text-primary-foreground ring-offset-background placeholder:text-muted-foreground shadow-xs flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base outline-none transition-[color,box-shadow] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
)}
|
||||
{type}
|
||||
bind:value
|
||||
{...restProps}
|
||||
/>
|
||||
{/if}
|
||||
7
packages/frontend/src/lib/components/ui/label/index.ts
Normal file
7
packages/frontend/src/lib/components/ui/label/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import Root from "./label.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Label,
|
||||
};
|
||||
20
packages/frontend/src/lib/components/ui/label/label.svelte
Normal file
20
packages/frontend/src/lib/components/ui/label/label.svelte
Normal file
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { Label as LabelPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: LabelPrimitive.RootProps = $props();
|
||||
</script>
|
||||
|
||||
<LabelPrimitive.Root
|
||||
bind:ref
|
||||
data-slot="label"
|
||||
class={cn(
|
||||
"flex select-none items-center gap-2 text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-50 group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -1 +0,0 @@
|
||||
// place files you want to import through the `$lib` alias in this folder.
|
||||
|
||||
61
packages/frontend/src/lib/stores/auth.store.ts
Normal file
61
packages/frontend/src/lib/stores/auth.store.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import { browser } from '$app/environment';
|
||||
import type { User } from '@open-archive/types';
|
||||
|
||||
interface AuthState {
|
||||
accessToken: string | null;
|
||||
user: Omit<User, 'passwordHash'> | null;
|
||||
}
|
||||
|
||||
const initialValue: AuthState = {
|
||||
accessToken: null,
|
||||
user: null,
|
||||
};
|
||||
|
||||
// Function to get the initial state from localStorage
|
||||
const getInitialState = (): AuthState => {
|
||||
if (!browser) {
|
||||
return initialValue;
|
||||
}
|
||||
|
||||
const storedToken = localStorage.getItem('accessToken');
|
||||
const storedUser = localStorage.getItem('user');
|
||||
|
||||
if (storedToken && storedUser) {
|
||||
try {
|
||||
return {
|
||||
accessToken: storedToken,
|
||||
user: JSON.parse(storedUser),
|
||||
};
|
||||
} catch (e) {
|
||||
console.error('Failed to parse user from localStorage', e);
|
||||
return initialValue;
|
||||
}
|
||||
}
|
||||
|
||||
return initialValue;
|
||||
};
|
||||
|
||||
const createAuthStore = () => {
|
||||
const { subscribe, set } = writable<AuthState>(getInitialState());
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
login: (accessToken: string, user: Omit<User, 'passwordHash'>) => {
|
||||
if (browser) {
|
||||
localStorage.setItem('accessToken', accessToken);
|
||||
localStorage.setItem('user', JSON.stringify(user));
|
||||
}
|
||||
set({ accessToken, user });
|
||||
},
|
||||
logout: () => {
|
||||
if (browser) {
|
||||
localStorage.removeItem('accessToken');
|
||||
localStorage.removeItem('user');
|
||||
}
|
||||
set(initialValue);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const authStore = createAuthStore();
|
||||
13
packages/frontend/src/lib/utils.ts
Normal file
13
packages/frontend/src/lib/utils.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type WithoutChild<T> = T extends { child?: any } ? Omit<T, "child"> : T;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type WithoutChildren<T> = T extends { children?: any } ? Omit<T, "children"> : T;
|
||||
export type WithoutChildrenOrChild<T> = WithoutChildren<WithoutChild<T>>;
|
||||
export type WithElementRef<T, U extends HTMLElement = HTMLElement> = T & { ref?: U | null };
|
||||
31
packages/frontend/src/routes/+layout.ts
Normal file
31
packages/frontend/src/routes/+layout.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { browser } from '$app/environment';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { get } from 'svelte/store';
|
||||
import { authStore } from '$lib/stores/auth.store';
|
||||
|
||||
// List of routes that are accessible to unauthenticated users.
|
||||
const publicRoutes = ['/signin'];
|
||||
|
||||
export const load = ({ url }) => {
|
||||
// Route protection should only run on the client side where the authStore
|
||||
// is reliably hydrated from localStorage.
|
||||
if (browser) {
|
||||
const { accessToken } = get(authStore);
|
||||
const isPublicRoute = publicRoutes.includes(url.pathname);
|
||||
|
||||
// If the user is not logged in and trying to access a private route...
|
||||
if (!accessToken && !isPublicRoute) {
|
||||
// ...redirect them to the sign-in page.
|
||||
throw redirect(307, '/signin');
|
||||
}
|
||||
|
||||
// If the user is logged in and trying to access a public route (like /signin)...
|
||||
if (accessToken && isPublicRoute) {
|
||||
// ...redirect them to the dashboard.
|
||||
throw redirect(307, '/dashboard');
|
||||
}
|
||||
}
|
||||
|
||||
// For all other cases, allow the page to load.
|
||||
return {};
|
||||
};
|
||||
@@ -1,2 +1,5 @@
|
||||
<script lang="ts">
|
||||
</script>
|
||||
|
||||
<h1>Welcome to SvelteKit</h1>
|
||||
<p>Visit <a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a> to read the documentation</p>
|
||||
|
||||
24
packages/frontend/src/routes/+page.ts
Normal file
24
packages/frontend/src/routes/+page.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { get } from 'svelte/store';
|
||||
import { authStore } from '$lib/stores/auth.store';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
export const load = () => {
|
||||
// This logic should only run on the client side where the authStore is hydrated
|
||||
// from localStorage.
|
||||
if (browser) {
|
||||
const { accessToken } = get(authStore);
|
||||
|
||||
if (accessToken) {
|
||||
// If logged in, go to the dashboard.
|
||||
throw redirect(307, '/dashboard');
|
||||
} else {
|
||||
// If not logged in, go to the sign-in page.
|
||||
throw redirect(307, '/signin');
|
||||
}
|
||||
}
|
||||
|
||||
// On the server, we don't know the auth state, so we don't redirect.
|
||||
// The client-side navigation will take over.
|
||||
return {};
|
||||
};
|
||||
23
packages/frontend/src/routes/dashboard/+page.svelte
Normal file
23
packages/frontend/src/routes/dashboard/+page.svelte
Normal file
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import { authStore } from '$lib/stores/auth.store';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
function handleLogout() {
|
||||
authStore.logout();
|
||||
goto('/signin');
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Dashboard - OpenArchive</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="p-8">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-bold">Dashboard</h1>
|
||||
<Button onclick={handleLogout}>Logout</Button>
|
||||
</div>
|
||||
<p class="mt-4">Welcome, {$authStore.user?.email}!</p>
|
||||
<p>You are logged in.</p>
|
||||
</div>
|
||||
75
packages/frontend/src/routes/signin/+page.svelte
Normal file
75
packages/frontend/src/routes/signin/+page.svelte
Normal file
@@ -0,0 +1,75 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { api } from '$lib/api';
|
||||
import { authStore } from '$lib/stores/auth.store';
|
||||
import type { LoginResponse } from '@open-archive/types';
|
||||
|
||||
let email = '';
|
||||
let password = '';
|
||||
let error: string | null = null;
|
||||
let isLoading = false;
|
||||
|
||||
async function handleSubmit() {
|
||||
isLoading = true;
|
||||
error = null;
|
||||
try {
|
||||
const response = await api('/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email, password })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.message || 'Failed to login');
|
||||
}
|
||||
|
||||
const loginData: LoginResponse = await response.json();
|
||||
authStore.login(loginData.accessToken, loginData.user);
|
||||
|
||||
// Redirect to a protected page after login
|
||||
goto('/dashboard');
|
||||
} catch (e: any) {
|
||||
error = e.message;
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Login - OpenArchive</title>
|
||||
<meta name="description" content="Login to your OpenArchive account." />
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex min-h-screen items-center justify-center bg-gray-100 dark:bg-gray-900">
|
||||
<Card.Root class="w-full max-w-md">
|
||||
<Card.Header class="space-y-1">
|
||||
<Card.Title class="text-2xl">Login</Card.Title>
|
||||
<Card.Description>Enter your email below to login to your account.</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content class="grid gap-4">
|
||||
<form onsubmit={handleSubmit}>
|
||||
<div class="grid gap-2">
|
||||
<Label for="email">Email</Label>
|
||||
<Input id="email" type="email" placeholder="m@example.com" bind:value={email} required />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="password">Password</Label>
|
||||
<Input id="password" type="password" bind:value={password} required />
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<p class="mt-2 text-sm text-red-600">{error}</p>
|
||||
{/if}
|
||||
|
||||
<Button type="submit" class="mt-4 w-full" disabled={isLoading}>
|
||||
{isLoading ? 'Logging in...' : 'Login'}
|
||||
</Button>
|
||||
</form>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
@@ -1,7 +1,20 @@
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss(), sveltekit()]
|
||||
plugins: [tailwindcss(), sveltekit()],
|
||||
server: {
|
||||
port: Number(process.env.PORT_FRONTEND) || 3000,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: `http://localhost:${process.env.PORT_BACKEND || 4000}`,
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/api/, '')
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -10,5 +10,8 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"jose": "^6.0.11"
|
||||
}
|
||||
}
|
||||
|
||||
31
packages/types/src/auth.types.ts
Normal file
31
packages/types/src/auth.types.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { JWTPayload } from 'jose';
|
||||
import type { User } from './user.types';
|
||||
|
||||
/**
|
||||
* Defines the payload structure for the JWT, extending the standard JWTPayload.
|
||||
* This is the data that will be encoded into the token.
|
||||
*/
|
||||
export interface AuthTokenPayload extends JWTPayload {
|
||||
/**
|
||||
* The user's email address.
|
||||
*/
|
||||
email: string;
|
||||
/**
|
||||
* The user's role, used for authorization.
|
||||
*/
|
||||
role: User['role'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines the structure of the response from a successful login request.
|
||||
*/
|
||||
export interface LoginResponse {
|
||||
/**
|
||||
* The JSON Web Token for authenticating subsequent requests.
|
||||
*/
|
||||
accessToken: string;
|
||||
/**
|
||||
* The authenticated user's information.
|
||||
*/
|
||||
user: Omit<User, 'passwordHash'>;
|
||||
}
|
||||
@@ -1,7 +1,2 @@
|
||||
export type UserRole = 'Admin' | 'Auditor' | 'EndUser';
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
role: UserRole;
|
||||
}
|
||||
export * from './auth.types';
|
||||
export * from './user.types';
|
||||
|
||||
26
packages/types/src/user.types.ts
Normal file
26
packages/types/src/user.types.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Defines the possible roles a user can have within the system.
|
||||
*/
|
||||
export type UserRole = 'Super Administrator' | 'Auditor/Compliance Officer' | 'End User';
|
||||
|
||||
/**
|
||||
* Represents a user account in the system.
|
||||
*/
|
||||
export interface User {
|
||||
/**
|
||||
* The unique identifier for the user.
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* The user's email address, used for login.
|
||||
*/
|
||||
email: string;
|
||||
/**
|
||||
* The user's assigned role, which determines their permissions.
|
||||
*/
|
||||
role: UserRole;
|
||||
/**
|
||||
* The hashed password for the user. This should never be exposed to the client.
|
||||
*/
|
||||
passwordHash: string;
|
||||
}
|
||||
1
packages/types/tsconfig.tsbuildinfo
Normal file
1
packages/types/tsconfig.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "node",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
|
||||
Reference in New Issue
Block a user