Admin user login support

This commit is contained in:
Wayne
2025-07-10 22:32:12 +03:00
parent f243775ae6
commit cc08f35ada
45 changed files with 1117 additions and 24 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -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"
]
}
}
}

View File

@@ -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",

View 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' });
}
};
}

View 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' });
}
};
};

View 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;
};

View File

@@ -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();

View 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;
}
}
}

View 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;
}
}

View File

@@ -3,7 +3,9 @@
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"composite": true
"composite": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"]

View 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"
}

View File

@@ -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"
}

View File

@@ -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;
}
}

View 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);
};

View 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}

View 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,
};

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View 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>

View 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,
};

View File

@@ -0,0 +1,7 @@
import Root from "./input.svelte";
export {
Root,
//
Root as Input,
};

View 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}

View File

@@ -0,0 +1,7 @@
import Root from "./label.svelte";
export {
Root,
//
Root as Label,
};

View 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}
/>

View File

@@ -1 +0,0 @@
// place files you want to import through the `$lib` alias in this folder.

View 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();

View 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 };

View 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 {};
};

View File

@@ -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>

View 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 {};
};

View 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>

View 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>

View File

@@ -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/, '')
}
}
}
});

View File

@@ -10,5 +10,8 @@
},
"devDependencies": {
"typescript": "^5.0.0"
},
"dependencies": {
"jose": "^6.0.11"
}
}

View 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'>;
}

View File

@@ -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';

View 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;
}

File diff suppressed because one or more lines are too long

View File

@@ -1,7 +1,7 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"module": "CommonJS",
"moduleResolution": "node",
"strict": true,
"esModuleInterop": true,