Add local authentication option (#37)
All checks were successful
Build and Push Image / docker-build-and-push (push) Successful in 2m20s
All checks were successful
Build and Push Image / docker-build-and-push (push) Successful in 2m20s
Co-authored-by: Shaun Campbell <shaun@campbellwireless.net> Reviewed-on: #37 Co-authored-by: AI Agent <ai-agent@campbellwireless.net> Co-committed-by: AI Agent <ai-agent@campbellwireless.net>
This commit was merged in pull request #37.
This commit is contained in:
@@ -10,6 +10,15 @@ AUTH_URL=https://cloud.campbellwireless.net/trips/auth
|
|||||||
# Auth.js secret — generate with: openssl rand -base64 32
|
# Auth.js secret — generate with: openssl rand -base64 32
|
||||||
AUTH_SECRET=
|
AUTH_SECRET=
|
||||||
|
|
||||||
|
# Local auth
|
||||||
|
LOCAL_AUTH_ENABLED=false
|
||||||
|
LOCAL_AUTH_ARGON2_MEMORY_KB=65536
|
||||||
|
LOCAL_AUTH_ARGON2_TIME_COST=3
|
||||||
|
LOCAL_AUTH_ARGON2_PARALLELISM=1
|
||||||
|
LOCAL_AUTH_MAX_ATTEMPTS=5
|
||||||
|
LOCAL_AUTH_WINDOW_SECONDS=900
|
||||||
|
LOCAL_AUTH_LOCKOUT_SECONDS=900
|
||||||
|
|
||||||
# Admin — comma-separated user IDs (from auth provider) that can access /admin
|
# Admin — comma-separated user IDs (from auth provider) that can access /admin
|
||||||
ADMIN_USER_IDS=
|
ADMIN_USER_IDS=
|
||||||
|
|
||||||
|
|||||||
13
README.md
13
README.md
@@ -43,6 +43,19 @@ bun install
|
|||||||
bun run dev
|
bun run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Local authentication
|
||||||
|
|
||||||
|
Local auth is optional and off by default. Enable it with the env vars in `.env.example` and ensure users have a matching row in both `users` and `local_credentials`.
|
||||||
|
|
||||||
|
To seed a local password hash, use Argon2id with the configured parameters and insert it into `local_credentials`:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
INSERT INTO local_credentials (user_id, password_hash)
|
||||||
|
VALUES ('<user-id>', '<argon2id hash>');
|
||||||
|
```
|
||||||
|
|
||||||
|
Passwords must be at least 12 characters. Avoid storing plaintext passwords anywhere.
|
||||||
|
|
||||||
## Quality and Tests
|
## Quality and Tests
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
9
bun.lock
9
bun.lock
@@ -6,6 +6,7 @@
|
|||||||
"name": "trips",
|
"name": "trips",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@auth/sveltekit": "^1.11.1",
|
"@auth/sveltekit": "^1.11.1",
|
||||||
|
"argon2": "^0.41.1",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.4.4",
|
"@biomejs/biome": "^2.4.4",
|
||||||
@@ -153,6 +154,8 @@
|
|||||||
|
|
||||||
"@panva/hkdf": ["@panva/hkdf@1.2.1", "", {}, "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw=="],
|
"@panva/hkdf": ["@panva/hkdf@1.2.1", "", {}, "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw=="],
|
||||||
|
|
||||||
|
"@phc/format": ["@phc/format@1.0.0", "", {}, "sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ=="],
|
||||||
|
|
||||||
"@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="],
|
"@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="],
|
||||||
|
|
||||||
"@rollup/plugin-commonjs": ["@rollup/plugin-commonjs@29.0.0", "", { "dependencies": { "@rollup/pluginutils": "^5.0.1", "commondir": "^1.0.1", "estree-walker": "^2.0.2", "fdir": "^6.2.0", "is-reference": "1.2.1", "magic-string": "^0.30.3", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^2.68.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-U2YHaxR2cU/yAiwKJtJRhnyLk7cifnQw0zUpISsocBDoHDJn+HTV74ABqnwr5bEgWUwFZC9oFL6wLe21lHu5eQ=="],
|
"@rollup/plugin-commonjs": ["@rollup/plugin-commonjs@29.0.0", "", { "dependencies": { "@rollup/pluginutils": "^5.0.1", "commondir": "^1.0.1", "estree-walker": "^2.0.2", "fdir": "^6.2.0", "is-reference": "1.2.1", "magic-string": "^0.30.3", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^2.68.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-U2YHaxR2cU/yAiwKJtJRhnyLk7cifnQw0zUpISsocBDoHDJn+HTV74ABqnwr5bEgWUwFZC9oFL6wLe21lHu5eQ=="],
|
||||||
@@ -315,6 +318,8 @@
|
|||||||
|
|
||||||
"ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="],
|
"ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="],
|
||||||
|
|
||||||
|
"argon2": ["argon2@0.41.1", "", { "dependencies": { "@phc/format": "^1.0.0", "node-addon-api": "^8.1.0", "node-gyp-build": "^4.8.1" } }, "sha512-dqCW8kJXke8Ik+McUcMDltrbuAWETPyU6iq+4AhxqKphWi7pChB/Zgd/Tp/o8xRLbg8ksMj46F/vph9wnxpTzQ=="],
|
||||||
|
|
||||||
"aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="],
|
"aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="],
|
||||||
|
|
||||||
"assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
|
"assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
|
||||||
@@ -509,6 +514,10 @@
|
|||||||
|
|
||||||
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
|
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
|
||||||
|
|
||||||
|
"node-addon-api": ["node-addon-api@8.5.0", "", {}, "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A=="],
|
||||||
|
|
||||||
|
"node-gyp-build": ["node-gyp-build@4.8.4", "", { "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", "node-gyp-build-test": "build-test.js" } }, "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ=="],
|
||||||
|
|
||||||
"oauth4webapi": ["oauth4webapi@3.8.5", "", {}, "sha512-A8jmyUckVhRJj5lspguklcl90Ydqk61H3dcU0oLhH3Yv13KpAliKTt5hknpGGPZSSfOwGyraNEFmofDYH+1kSg=="],
|
"oauth4webapi": ["oauth4webapi@3.8.5", "", {}, "sha512-A8jmyUckVhRJj5lspguklcl90Ydqk61H3dcU0oLhH3Yv13KpAliKTt5hknpGGPZSSfOwGyraNEFmofDYH+1kSg=="],
|
||||||
|
|
||||||
"obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="],
|
"obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="],
|
||||||
|
|||||||
@@ -40,6 +40,7 @@
|
|||||||
"vitest": "^4.0.18"
|
"vitest": "^4.0.18"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@auth/sveltekit": "^1.11.1"
|
"@auth/sveltekit": "^1.11.1",
|
||||||
|
"argon2": "^0.41.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
30
src/auth.ts
30
src/auth.ts
@@ -1,6 +1,8 @@
|
|||||||
import { SvelteKitAuth } from '@auth/sveltekit';
|
import { SvelteKitAuth } from '@auth/sveltekit';
|
||||||
|
import Credentials from '@auth/core/providers/credentials';
|
||||||
import { env } from '$env/dynamic/private';
|
import { env } from '$env/dynamic/private';
|
||||||
import { upsertUserFromAuth } from '$lib/server/users.js';
|
import { upsertUserFromAuth } from '$lib/server/users.js';
|
||||||
|
import { verifyLocalCredentials } from '$lib/server/local-auth.js';
|
||||||
|
|
||||||
export const { handle, signIn, signOut } = SvelteKitAuth({
|
export const { handle, signIn, signOut } = SvelteKitAuth({
|
||||||
providers: [
|
providers: [
|
||||||
@@ -19,11 +21,34 @@ export const { handle, signIn, signOut } = SvelteKitAuth({
|
|||||||
email: profile.email as string | undefined
|
email: profile.email as string | undefined
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
Credentials({
|
||||||
|
id: 'local',
|
||||||
|
name: 'Local',
|
||||||
|
credentials: {
|
||||||
|
identifier: { label: 'Username or email', type: 'text' },
|
||||||
|
password: { label: 'Password', type: 'password' }
|
||||||
|
},
|
||||||
|
async authorize(credentials, request) {
|
||||||
|
const identifier = String(credentials?.identifier ?? '').trim();
|
||||||
|
const password = String(credentials?.password ?? '').trim();
|
||||||
|
const ip =
|
||||||
|
request?.headers?.get?.('x-forwarded-for') ??
|
||||||
|
request?.headers?.get?.('x-real-ip') ??
|
||||||
|
undefined;
|
||||||
|
const result = await verifyLocalCredentials(identifier, password, ip);
|
||||||
|
if (result.status !== 'success') return null;
|
||||||
|
return {
|
||||||
|
id: result.user.id,
|
||||||
|
name: result.user.name,
|
||||||
|
email: result.user.email ?? undefined
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
})
|
||||||
],
|
],
|
||||||
trustHost: true,
|
trustHost: true,
|
||||||
callbacks: {
|
callbacks: {
|
||||||
jwt({ token, profile }) {
|
jwt({ token, profile, user }) {
|
||||||
const details = profile as
|
const details = profile as
|
||||||
| {
|
| {
|
||||||
sub?: string;
|
sub?: string;
|
||||||
@@ -43,6 +68,9 @@ export const { handle, signIn, signOut } = SvelteKitAuth({
|
|||||||
authSource: 'OIDC - Synology'
|
authSource: 'OIDC - Synology'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (user?.id) {
|
||||||
|
token.sub = user.id as string;
|
||||||
|
}
|
||||||
return token;
|
return token;
|
||||||
},
|
},
|
||||||
session({ session, token }) {
|
session({ session, token }) {
|
||||||
|
|||||||
81
src/lib/server/admin/auth.test.ts
Normal file
81
src/lib/server/admin/auth.test.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { setupTestDb } from '../../../tests/helpers.js';
|
||||||
|
import type { Database } from '../db/types.js';
|
||||||
|
import { upsertUserFromAuth } from '../users.js';
|
||||||
|
import { isAdminUser, requireAdmin } from './auth.js';
|
||||||
|
|
||||||
|
let database: Database;
|
||||||
|
const originalAdminUserIds = process.env.ADMIN_USER_IDS;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
database = setupTestDb();
|
||||||
|
process.env.ADMIN_USER_IDS = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env.ADMIN_USER_IDS = originalAdminUserIds;
|
||||||
|
database.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isAdminUser', () => {
|
||||||
|
it('allows direct ID matches', () => {
|
||||||
|
process.env.ADMIN_USER_IDS = 'oidc-sub-123';
|
||||||
|
expect(isAdminUser('oidc-sub-123')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows username matches for known users', () => {
|
||||||
|
upsertUserFromAuth({
|
||||||
|
id: 'oidc-sub-123',
|
||||||
|
username: 'shaun',
|
||||||
|
fullName: 'Shaun Campbell',
|
||||||
|
email: 'shaun@example.com'
|
||||||
|
});
|
||||||
|
process.env.ADMIN_USER_IDS = 'shaun';
|
||||||
|
expect(isAdminUser('oidc-sub-123')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows email matches for known users', () => {
|
||||||
|
upsertUserFromAuth({
|
||||||
|
id: 'oidc-sub-123',
|
||||||
|
username: 'shaun',
|
||||||
|
fullName: 'Shaun Campbell',
|
||||||
|
email: 'shaun@example.com'
|
||||||
|
});
|
||||||
|
process.env.ADMIN_USER_IDS = 'shaun@example.com';
|
||||||
|
expect(isAdminUser('oidc-sub-123')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows email local-part matches from session user', () => {
|
||||||
|
process.env.ADMIN_USER_IDS = 'shaun';
|
||||||
|
expect(isAdminUser({ id: 'oidc-sub-123', email: 'shaun@example.com' })).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('requireAdmin', () => {
|
||||||
|
it('throws when not authenticated', () => {
|
||||||
|
process.env.ADMIN_USER_IDS = 'shaun';
|
||||||
|
expect(() => requireAdmin(undefined)).toThrow('Not authenticated');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when user is not admin', () => {
|
||||||
|
process.env.ADMIN_USER_IDS = 'shaun';
|
||||||
|
expect(() => requireAdmin('someone-else')).toThrow('Admin access required');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not throw when username match grants admin access', () => {
|
||||||
|
upsertUserFromAuth({
|
||||||
|
id: 'oidc-sub-123',
|
||||||
|
username: 'shaun',
|
||||||
|
fullName: 'Shaun Campbell'
|
||||||
|
});
|
||||||
|
process.env.ADMIN_USER_IDS = 'shaun';
|
||||||
|
expect(() => requireAdmin('oidc-sub-123')).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not throw when session user email local-part matches', () => {
|
||||||
|
process.env.ADMIN_USER_IDS = 'shaun';
|
||||||
|
expect(() =>
|
||||||
|
requireAdmin({ id: 'oidc-sub-123', email: 'shaun@example.com', name: 'Shaun Campbell' })
|
||||||
|
).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,12 +1,73 @@
|
|||||||
import { env } from '$env/dynamic/private';
|
import { env } from '$env/dynamic/private';
|
||||||
|
import { db } from '$lib/server/db/index.js';
|
||||||
|
|
||||||
export function requireAdmin(userId: string | undefined): void {
|
const getAdminIdentifiers = (): string[] =>
|
||||||
if (!userId) throw new Error('Not authenticated');
|
(process.env.ADMIN_USER_IDS ?? env.ADMIN_USER_IDS ?? '')
|
||||||
const adminIds = (env.ADMIN_USER_IDS ?? '')
|
|
||||||
.split(',')
|
.split(',')
|
||||||
.map((s) => s.trim())
|
.map((s) => s.trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
if (adminIds.length === 0 || !adminIds.includes(userId)) {
|
|
||||||
|
type AdminPrincipal =
|
||||||
|
| string
|
||||||
|
| {
|
||||||
|
id?: string | null;
|
||||||
|
name?: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
const normalize = (value: string): string => value.trim().toLowerCase();
|
||||||
|
|
||||||
|
const getPrincipalValues = (principal: AdminPrincipal): { userId?: string; values: string[] } => {
|
||||||
|
if (!principal) return { values: [] };
|
||||||
|
if (typeof principal === 'string') {
|
||||||
|
const value = principal.trim();
|
||||||
|
return value ? { userId: value, values: [value] } : { values: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const values = [principal.id, principal.name, principal.email]
|
||||||
|
.filter((value): value is string => Boolean(value?.trim()))
|
||||||
|
.map((value) => value.trim());
|
||||||
|
const localPart = principal.email?.split('@')[0]?.trim();
|
||||||
|
if (localPart) values.push(localPart);
|
||||||
|
|
||||||
|
const userId = principal.id?.trim();
|
||||||
|
return { userId, values };
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isAdminUser(principal: AdminPrincipal): boolean {
|
||||||
|
const { userId, values } = getPrincipalValues(principal);
|
||||||
|
if (values.length === 0) return false;
|
||||||
|
|
||||||
|
const adminIds = getAdminIdentifiers();
|
||||||
|
if (adminIds.length === 0) return false;
|
||||||
|
|
||||||
|
const normalizedAdminIds = new Set(adminIds.map(normalize));
|
||||||
|
for (const value of values) {
|
||||||
|
if (adminIds.includes(value) || normalizedAdminIds.has(normalize(value))) return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!userId) return false;
|
||||||
|
|
||||||
|
const user = db.get<{ username: string; email: string | null }>(
|
||||||
|
'SELECT username, email FROM users WHERE id = ?',
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
if (!user) return false;
|
||||||
|
|
||||||
|
const dbValues = [user.username, user.email, user.email?.split('@')[0]]
|
||||||
|
.filter((value): value is string => Boolean(value?.trim()))
|
||||||
|
.map((value) => value.trim());
|
||||||
|
for (const value of dbValues) {
|
||||||
|
if (adminIds.includes(value) || normalizedAdminIds.has(normalize(value))) return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireAdmin(principal: AdminPrincipal): void {
|
||||||
|
if (!principal) throw new Error('Not authenticated');
|
||||||
|
if (!isAdminUser(principal)) {
|
||||||
throw new Error('Admin access required');
|
throw new Error('Admin access required');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,6 +65,15 @@ export function runMigrations(db: Database): void {
|
|||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS local_credentials (
|
||||||
|
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
db.run(`
|
db.run(`
|
||||||
CREATE TABLE IF NOT EXISTS cities (
|
CREATE TABLE IF NOT EXISTS cities (
|
||||||
id INTEGER PRIMARY KEY,
|
id INTEGER PRIMARY KEY,
|
||||||
|
|||||||
84
src/lib/server/local-auth.test.ts
Normal file
84
src/lib/server/local-auth.test.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { setupTestDb } from '../../tests/helpers.js';
|
||||||
|
import type { Database } from './db/types.js';
|
||||||
|
import { setLocalCredentialPassword } from './local-credentials.js';
|
||||||
|
import { resetLocalAuthRateLimits, verifyLocalCredentials } from './local-auth.js';
|
||||||
|
import { upsertUserFromAuth } from './users.js';
|
||||||
|
|
||||||
|
let database: Database;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.LOCAL_AUTH_ENABLED = 'true';
|
||||||
|
process.env.LOCAL_AUTH_ARGON2_MEMORY_KB = '8192';
|
||||||
|
process.env.LOCAL_AUTH_ARGON2_TIME_COST = '2';
|
||||||
|
process.env.LOCAL_AUTH_ARGON2_PARALLELISM = '1';
|
||||||
|
process.env.LOCAL_AUTH_MAX_ATTEMPTS = '2';
|
||||||
|
process.env.LOCAL_AUTH_WINDOW_SECONDS = '60';
|
||||||
|
process.env.LOCAL_AUTH_LOCKOUT_SECONDS = '10';
|
||||||
|
database = setupTestDb();
|
||||||
|
resetLocalAuthRateLimits();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
resetLocalAuthRateLimits();
|
||||||
|
database.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('verifyLocalCredentials', () => {
|
||||||
|
it('authenticates valid local credentials', async () => {
|
||||||
|
upsertUserFromAuth({
|
||||||
|
id: 'user-1',
|
||||||
|
username: 'jdoe',
|
||||||
|
fullName: 'Jane Doe',
|
||||||
|
email: 'jane@example.com',
|
||||||
|
authSource: 'Local'
|
||||||
|
});
|
||||||
|
await setLocalCredentialPassword('user-1', 'averysecurepassword');
|
||||||
|
|
||||||
|
const result = await verifyLocalCredentials('jdoe', 'averysecurepassword', '127.0.0.1');
|
||||||
|
|
||||||
|
expect(result.status).toBe('success');
|
||||||
|
if (result.status === 'success') {
|
||||||
|
expect(result.user.id).toBe('user-1');
|
||||||
|
expect(result.user.email).toBe('jane@example.com');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns invalid for bad credentials and locks after max attempts', async () => {
|
||||||
|
upsertUserFromAuth({ id: 'user-2', username: 'sally', fullName: 'Sally Sample' });
|
||||||
|
await setLocalCredentialPassword('user-2', 'averysecurepassword');
|
||||||
|
|
||||||
|
const first = await verifyLocalCredentials('sally', 'wrong-password', '10.0.0.1');
|
||||||
|
const second = await verifyLocalCredentials('sally', 'wrong-password', '10.0.0.1');
|
||||||
|
const third = await verifyLocalCredentials('sally', 'wrong-password', '10.0.0.1');
|
||||||
|
|
||||||
|
expect(first.status).toBe('invalid');
|
||||||
|
expect(second.status).toBe('invalid');
|
||||||
|
expect(third.status).toBe('locked');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears lockout after window expires', async () => {
|
||||||
|
upsertUserFromAuth({ id: 'user-3', username: 'morgan', fullName: 'Morgan West' });
|
||||||
|
await setLocalCredentialPassword('user-3', 'averysecurepassword');
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date('2024-01-01T00:00:00Z'));
|
||||||
|
|
||||||
|
await verifyLocalCredentials('morgan', 'wrong-password', '10.0.0.2');
|
||||||
|
await verifyLocalCredentials('morgan', 'wrong-password', '10.0.0.2');
|
||||||
|
const locked = await verifyLocalCredentials('morgan', 'averysecurepassword', '10.0.0.2');
|
||||||
|
|
||||||
|
expect(locked.status).toBe('locked');
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(11_000);
|
||||||
|
const after = await verifyLocalCredentials('morgan', 'averysecurepassword', '10.0.0.2');
|
||||||
|
|
||||||
|
expect(after.status).toBe('success');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects when local auth is disabled', async () => {
|
||||||
|
process.env.LOCAL_AUTH_ENABLED = 'false';
|
||||||
|
const result = await verifyLocalCredentials('anyone', 'password', '10.0.0.3');
|
||||||
|
expect(result.status).toBe('disabled');
|
||||||
|
});
|
||||||
|
});
|
||||||
154
src/lib/server/local-auth.ts
Normal file
154
src/lib/server/local-auth.ts
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
import { env } from '$env/dynamic/private';
|
||||||
|
import argon2 from 'argon2';
|
||||||
|
import { getLocalCredentialByIdentifier, hashLocalPassword } from './local-credentials.js';
|
||||||
|
|
||||||
|
export interface LocalAuthUser {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LocalAuthResult =
|
||||||
|
| { status: 'success'; user: LocalAuthUser }
|
||||||
|
| { status: 'invalid' | 'locked' | 'disabled' };
|
||||||
|
|
||||||
|
interface AttemptState {
|
||||||
|
count: number;
|
||||||
|
firstAttemptAt: number;
|
||||||
|
lockedUntil?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const attemptsByIdentifier = new Map<string, AttemptState>();
|
||||||
|
const attemptsByIp = new Map<string, AttemptState>();
|
||||||
|
let dummyHashPromise: Promise<string> | null = null;
|
||||||
|
|
||||||
|
const getEnvValue = (key: keyof typeof env): string | undefined => {
|
||||||
|
return process.env[key] ?? env[key];
|
||||||
|
};
|
||||||
|
|
||||||
|
const getNumberEnv = (value: string | undefined, fallback: number): number => {
|
||||||
|
const parsed = Number.parseInt(value ?? '', 10);
|
||||||
|
return Number.isFinite(parsed) ? parsed : fallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLocalAuthConfig = () => {
|
||||||
|
return {
|
||||||
|
enabled: getEnvValue('LOCAL_AUTH_ENABLED') === 'true',
|
||||||
|
maxAttempts: getNumberEnv(getEnvValue('LOCAL_AUTH_MAX_ATTEMPTS'), 5),
|
||||||
|
windowMs: getNumberEnv(getEnvValue('LOCAL_AUTH_WINDOW_SECONDS'), 900) * 1000,
|
||||||
|
lockoutMs: getNumberEnv(getEnvValue('LOCAL_AUTH_LOCKOUT_SECONDS'), 900) * 1000
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeIdentifier = (value: string): string => value.trim().toLowerCase();
|
||||||
|
const normalizePassword = (value: string): string => value.trim();
|
||||||
|
|
||||||
|
const normalizeIp = (value?: string | null): string => {
|
||||||
|
if (!value) return 'unknown';
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) return 'unknown';
|
||||||
|
return trimmed.split(',')[0]?.trim() || 'unknown';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDummyHash = async (): Promise<string> => {
|
||||||
|
if (!dummyHashPromise) {
|
||||||
|
dummyHashPromise = hashLocalPassword('invalid-password-placeholder');
|
||||||
|
}
|
||||||
|
return dummyHashPromise;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getEffectiveState = (state: AttemptState | undefined, now: number, windowMs: number) => {
|
||||||
|
if (!state) return { count: 0, firstAttemptAt: now };
|
||||||
|
if (state.lockedUntil && state.lockedUntil <= now) {
|
||||||
|
return { count: 0, firstAttemptAt: now };
|
||||||
|
}
|
||||||
|
if (now - state.firstAttemptAt > windowMs) {
|
||||||
|
return { count: 0, firstAttemptAt: now };
|
||||||
|
}
|
||||||
|
return { ...state };
|
||||||
|
};
|
||||||
|
|
||||||
|
const isLocked = (state: AttemptState, now: number): boolean => {
|
||||||
|
return Boolean(state.lockedUntil && state.lockedUntil > now);
|
||||||
|
};
|
||||||
|
|
||||||
|
const recordFailure = (
|
||||||
|
map: Map<string, AttemptState>,
|
||||||
|
key: string,
|
||||||
|
now: number,
|
||||||
|
windowMs: number,
|
||||||
|
lockoutMs: number,
|
||||||
|
maxAttempts: number
|
||||||
|
): void => {
|
||||||
|
const state = getEffectiveState(map.get(key), now, windowMs);
|
||||||
|
const nextCount = state.count + 1;
|
||||||
|
const nextState: AttemptState = {
|
||||||
|
count: nextCount,
|
||||||
|
firstAttemptAt: state.firstAttemptAt
|
||||||
|
};
|
||||||
|
if (nextCount >= maxAttempts) {
|
||||||
|
nextState.lockedUntil = now + lockoutMs;
|
||||||
|
}
|
||||||
|
map.set(key, nextState);
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearAttemptState = (map: Map<string, AttemptState>, key: string): void => {
|
||||||
|
map.delete(key);
|
||||||
|
};
|
||||||
|
|
||||||
|
export function resetLocalAuthRateLimits(): void {
|
||||||
|
attemptsByIdentifier.clear();
|
||||||
|
attemptsByIp.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyLocalCredentials(
|
||||||
|
identifierInput: string,
|
||||||
|
passwordInput: string,
|
||||||
|
ipAddress?: string | null
|
||||||
|
): Promise<LocalAuthResult> {
|
||||||
|
const config = getLocalAuthConfig();
|
||||||
|
if (!config.enabled) return { status: 'disabled' };
|
||||||
|
|
||||||
|
const identifier = normalizeIdentifier(identifierInput ?? '');
|
||||||
|
const password = normalizePassword(passwordInput ?? '');
|
||||||
|
const identifierKey = identifier || 'unknown';
|
||||||
|
const ipKey = normalizeIp(ipAddress);
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
const identifierState = getEffectiveState(
|
||||||
|
attemptsByIdentifier.get(identifierKey),
|
||||||
|
now,
|
||||||
|
config.windowMs
|
||||||
|
);
|
||||||
|
const ipState = getEffectiveState(attemptsByIp.get(ipKey), now, config.windowMs);
|
||||||
|
const locked = isLocked(identifierState, now) || isLocked(ipState, now);
|
||||||
|
|
||||||
|
const record = identifier ? getLocalCredentialByIdentifier(identifier) : null;
|
||||||
|
const passwordHash = record?.passwordHash ?? (await getDummyHash());
|
||||||
|
const passwordMatches = await argon2.verify(passwordHash, password);
|
||||||
|
|
||||||
|
if (!locked && record && passwordMatches) {
|
||||||
|
clearAttemptState(attemptsByIdentifier, identifierKey);
|
||||||
|
clearAttemptState(attemptsByIp, ipKey);
|
||||||
|
return {
|
||||||
|
status: 'success',
|
||||||
|
user: {
|
||||||
|
id: record.userId,
|
||||||
|
name: record.fullName || record.username,
|
||||||
|
email: record.email ?? undefined
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
recordFailure(
|
||||||
|
attemptsByIdentifier,
|
||||||
|
identifierKey,
|
||||||
|
now,
|
||||||
|
config.windowMs,
|
||||||
|
config.lockoutMs,
|
||||||
|
config.maxAttempts
|
||||||
|
);
|
||||||
|
recordFailure(attemptsByIp, ipKey, now, config.windowMs, config.lockoutMs, config.maxAttempts);
|
||||||
|
|
||||||
|
return { status: locked ? 'locked' : 'invalid' };
|
||||||
|
}
|
||||||
67
src/lib/server/local-credentials.ts
Normal file
67
src/lib/server/local-credentials.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { env } from '$env/dynamic/private';
|
||||||
|
import argon2 from 'argon2';
|
||||||
|
import { db } from './db/index.js';
|
||||||
|
|
||||||
|
export interface LocalCredentialRecord {
|
||||||
|
userId: string;
|
||||||
|
username: string;
|
||||||
|
fullName: string;
|
||||||
|
email: string | null;
|
||||||
|
passwordHash: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LOCAL_AUTH_MIN_PASSWORD_LENGTH = 12;
|
||||||
|
|
||||||
|
const getNumberEnv = (value: string | undefined, fallback: number): number => {
|
||||||
|
const parsed = Number.parseInt(value ?? '', 10);
|
||||||
|
return Number.isFinite(parsed) ? parsed : fallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getLocalCredentialByIdentifier(identifier: string): LocalCredentialRecord | null {
|
||||||
|
const normalized = identifier.trim();
|
||||||
|
if (!normalized) return null;
|
||||||
|
return (
|
||||||
|
db.get<LocalCredentialRecord>(
|
||||||
|
`SELECT
|
||||||
|
users.id as userId,
|
||||||
|
users.username as username,
|
||||||
|
users.full_name as fullName,
|
||||||
|
users.email as email,
|
||||||
|
local_credentials.password_hash as passwordHash
|
||||||
|
FROM users
|
||||||
|
INNER JOIN local_credentials ON local_credentials.user_id = users.id
|
||||||
|
WHERE lower(users.username) = lower(?)
|
||||||
|
OR (users.email IS NOT NULL AND lower(users.email) = lower(?))
|
||||||
|
LIMIT 1`,
|
||||||
|
[normalized, normalized]
|
||||||
|
) ?? null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function hashLocalPassword(password: string): Promise<string> {
|
||||||
|
const trimmed = password.trim();
|
||||||
|
if (trimmed.length < LOCAL_AUTH_MIN_PASSWORD_LENGTH) {
|
||||||
|
throw new Error(`Password must be at least ${LOCAL_AUTH_MIN_PASSWORD_LENGTH} characters`);
|
||||||
|
}
|
||||||
|
const memoryCost = getNumberEnv(env.LOCAL_AUTH_ARGON2_MEMORY_KB, 65536);
|
||||||
|
const timeCost = getNumberEnv(env.LOCAL_AUTH_ARGON2_TIME_COST, 3);
|
||||||
|
const parallelism = getNumberEnv(env.LOCAL_AUTH_ARGON2_PARALLELISM, 1);
|
||||||
|
return argon2.hash(trimmed, {
|
||||||
|
type: argon2.argon2id,
|
||||||
|
memoryCost,
|
||||||
|
timeCost,
|
||||||
|
parallelism
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setLocalCredentialPassword(userId: string, password: string): Promise<void> {
|
||||||
|
const passwordHash = await hashLocalPassword(password);
|
||||||
|
db.run(
|
||||||
|
`INSERT INTO local_credentials (user_id, password_hash)
|
||||||
|
VALUES (?, ?)
|
||||||
|
ON CONFLICT(user_id) DO UPDATE SET
|
||||||
|
password_hash = excluded.password_hash,
|
||||||
|
updated_at = datetime('now')`,
|
||||||
|
[userId, passwordHash]
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { redirect } from '@sveltejs/kit';
|
import { redirect } from '@sveltejs/kit';
|
||||||
import { base } from '$app/paths';
|
import { base } from '$app/paths';
|
||||||
import { env } from '$env/dynamic/private';
|
import { isAdminUser } from '$lib/server/admin/auth.js';
|
||||||
import type { LayoutServerLoad } from './$types';
|
import type { LayoutServerLoad } from './$types';
|
||||||
|
|
||||||
export const load: LayoutServerLoad = async (event) => {
|
export const load: LayoutServerLoad = async (event) => {
|
||||||
@@ -9,11 +9,7 @@ export const load: LayoutServerLoad = async (event) => {
|
|||||||
redirect(303, `${base}/login`);
|
redirect(303, `${base}/login`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const adminIds = (env.ADMIN_USER_IDS ?? '')
|
const isAdmin = isAdminUser(session.user);
|
||||||
.split(',')
|
|
||||||
.map((s) => s.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
const isAdmin = adminIds.length > 0 && session.user.id && adminIds.includes(session.user.id);
|
|
||||||
|
|
||||||
return { session, isAdmin };
|
return { session, isAdmin };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ import { requireAdmin } from '$lib/server/admin/auth.js';
|
|||||||
import * as data from '$lib/server/admin/data.js';
|
import * as data from '$lib/server/admin/data.js';
|
||||||
|
|
||||||
export const GET: RequestHandler = async (event) => {
|
export const GET: RequestHandler = async (event) => {
|
||||||
requireAdmin((await event.locals.auth())?.user?.id);
|
requireAdmin((await event.locals.auth())?.user);
|
||||||
const q = event.url.searchParams.get('q') ?? undefined;
|
const q = event.url.searchParams.get('q') ?? undefined;
|
||||||
return json(data.listAirlines(q));
|
return json(data.listAirlines(q));
|
||||||
};
|
};
|
||||||
|
|
||||||
export const POST: RequestHandler = async (event) => {
|
export const POST: RequestHandler = async (event) => {
|
||||||
requireAdmin((await event.locals.auth())?.user?.id);
|
requireAdmin((await event.locals.auth())?.user);
|
||||||
const body = await event.request.json();
|
const body = await event.request.json();
|
||||||
const { name, country, country_code, iata_code, icao_code } = body as {
|
const { name, country, country_code, iata_code, icao_code } = body as {
|
||||||
name?: string;
|
name?: string;
|
||||||
@@ -32,7 +32,7 @@ export const POST: RequestHandler = async (event) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const PATCH: RequestHandler = async (event) => {
|
export const PATCH: RequestHandler = async (event) => {
|
||||||
requireAdmin((await event.locals.auth())?.user?.id);
|
requireAdmin((await event.locals.auth())?.user);
|
||||||
const body = await event.request.json();
|
const body = await event.request.json();
|
||||||
const { id, name, country, country_code, iata_code, icao_code } = body as {
|
const { id, name, country, country_code, iata_code, icao_code } = body as {
|
||||||
id?: number;
|
id?: number;
|
||||||
@@ -55,7 +55,7 @@ export const PATCH: RequestHandler = async (event) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const DELETE: RequestHandler = async (event) => {
|
export const DELETE: RequestHandler = async (event) => {
|
||||||
requireAdmin((await event.locals.auth())?.user?.id);
|
requireAdmin((await event.locals.auth())?.user);
|
||||||
const id = parseInt(event.url.searchParams.get('id') ?? '');
|
const id = parseInt(event.url.searchParams.get('id') ?? '');
|
||||||
if (isNaN(id)) return json({ error: 'id required' }, { status: 400 });
|
if (isNaN(id)) return json({ error: 'id required' }, { status: 400 });
|
||||||
data.deleteAirline(id);
|
data.deleteAirline(id);
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ import { requireAdmin } from '$lib/server/admin/auth.js';
|
|||||||
import * as data from '$lib/server/admin/data.js';
|
import * as data from '$lib/server/admin/data.js';
|
||||||
|
|
||||||
export const GET: RequestHandler = async (event) => {
|
export const GET: RequestHandler = async (event) => {
|
||||||
requireAdmin((await event.locals.auth())?.user?.id);
|
requireAdmin((await event.locals.auth())?.user);
|
||||||
const q = event.url.searchParams.get('q') ?? undefined;
|
const q = event.url.searchParams.get('q') ?? undefined;
|
||||||
return json(data.listAirports(q));
|
return json(data.listAirports(q));
|
||||||
};
|
};
|
||||||
|
|
||||||
export const POST: RequestHandler = async (event) => {
|
export const POST: RequestHandler = async (event) => {
|
||||||
requireAdmin((await event.locals.auth())?.user?.id);
|
requireAdmin((await event.locals.auth())?.user);
|
||||||
const body = await event.request.json();
|
const body = await event.request.json();
|
||||||
const { name, country, country_code, iata_code, icao_code, city, latitude, longitude, timezone } =
|
const { name, country, country_code, iata_code, icao_code, city, latitude, longitude, timezone } =
|
||||||
body as {
|
body as {
|
||||||
@@ -43,7 +43,7 @@ export const POST: RequestHandler = async (event) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const PATCH: RequestHandler = async (event) => {
|
export const PATCH: RequestHandler = async (event) => {
|
||||||
requireAdmin((await event.locals.auth())?.user?.id);
|
requireAdmin((await event.locals.auth())?.user);
|
||||||
const body = await event.request.json();
|
const body = await event.request.json();
|
||||||
const {
|
const {
|
||||||
id,
|
id,
|
||||||
@@ -87,7 +87,7 @@ export const PATCH: RequestHandler = async (event) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const DELETE: RequestHandler = async (event) => {
|
export const DELETE: RequestHandler = async (event) => {
|
||||||
requireAdmin((await event.locals.auth())?.user?.id);
|
requireAdmin((await event.locals.auth())?.user);
|
||||||
const id = parseInt(event.url.searchParams.get('id') ?? '');
|
const id = parseInt(event.url.searchParams.get('id') ?? '');
|
||||||
if (isNaN(id)) return json({ error: 'id required' }, { status: 400 });
|
if (isNaN(id)) return json({ error: 'id required' }, { status: 400 });
|
||||||
data.deleteAirport(id);
|
data.deleteAirport(id);
|
||||||
|
|||||||
@@ -4,14 +4,14 @@ import { requireAdmin } from '$lib/server/admin/auth.js';
|
|||||||
import * as data from '$lib/server/admin/data.js';
|
import * as data from '$lib/server/admin/data.js';
|
||||||
|
|
||||||
export const GET: RequestHandler = async (event) => {
|
export const GET: RequestHandler = async (event) => {
|
||||||
requireAdmin((await event.locals.auth())?.user?.id);
|
requireAdmin((await event.locals.auth())?.user);
|
||||||
const q = event.url.searchParams.get('q') ?? undefined;
|
const q = event.url.searchParams.get('q') ?? undefined;
|
||||||
const countryCode = event.url.searchParams.get('country_code') ?? undefined;
|
const countryCode = event.url.searchParams.get('country_code') ?? undefined;
|
||||||
return json(data.listCities(q, countryCode));
|
return json(data.listCities(q, countryCode));
|
||||||
};
|
};
|
||||||
|
|
||||||
export const POST: RequestHandler = async (event) => {
|
export const POST: RequestHandler = async (event) => {
|
||||||
requireAdmin((await event.locals.auth())?.user?.id);
|
requireAdmin((await event.locals.auth())?.user);
|
||||||
const body = await event.request.json();
|
const body = await event.request.json();
|
||||||
const { name, country, country_code, population } = body as {
|
const { name, country, country_code, population } = body as {
|
||||||
name?: string;
|
name?: string;
|
||||||
@@ -26,7 +26,7 @@ export const POST: RequestHandler = async (event) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const PATCH: RequestHandler = async (event) => {
|
export const PATCH: RequestHandler = async (event) => {
|
||||||
requireAdmin((await event.locals.auth())?.user?.id);
|
requireAdmin((await event.locals.auth())?.user);
|
||||||
const body = await event.request.json();
|
const body = await event.request.json();
|
||||||
const { id, name, country, country_code, population } = body as {
|
const { id, name, country, country_code, population } = body as {
|
||||||
id?: number;
|
id?: number;
|
||||||
@@ -43,7 +43,7 @@ export const PATCH: RequestHandler = async (event) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const DELETE: RequestHandler = async (event) => {
|
export const DELETE: RequestHandler = async (event) => {
|
||||||
requireAdmin((await event.locals.auth())?.user?.id);
|
requireAdmin((await event.locals.auth())?.user);
|
||||||
const id = parseInt(event.url.searchParams.get('id') ?? '');
|
const id = parseInt(event.url.searchParams.get('id') ?? '');
|
||||||
if (isNaN(id)) return json({ error: 'id required' }, { status: 400 });
|
if (isNaN(id)) return json({ error: 'id required' }, { status: 400 });
|
||||||
data.deleteCity(id);
|
data.deleteCity(id);
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ import { requireAdmin } from '$lib/server/admin/auth.js';
|
|||||||
import * as data from '$lib/server/admin/data.js';
|
import * as data from '$lib/server/admin/data.js';
|
||||||
|
|
||||||
export const GET: RequestHandler = async (event) => {
|
export const GET: RequestHandler = async (event) => {
|
||||||
requireAdmin((await event.locals.auth())?.user?.id);
|
requireAdmin((await event.locals.auth())?.user);
|
||||||
const q = event.url.searchParams.get('q') ?? undefined;
|
const q = event.url.searchParams.get('q') ?? undefined;
|
||||||
return json(data.listCountries(q));
|
return json(data.listCountries(q));
|
||||||
};
|
};
|
||||||
|
|
||||||
export const POST: RequestHandler = async (event) => {
|
export const POST: RequestHandler = async (event) => {
|
||||||
requireAdmin((await event.locals.auth())?.user?.id);
|
requireAdmin((await event.locals.auth())?.user);
|
||||||
const body = await event.request.json();
|
const body = await event.request.json();
|
||||||
const { name, country_code } = body as { name?: string; country_code?: string };
|
const { name, country_code } = body as { name?: string; country_code?: string };
|
||||||
if (!name?.trim() || !country_code?.trim()) {
|
if (!name?.trim() || !country_code?.trim()) {
|
||||||
@@ -20,7 +20,7 @@ export const POST: RequestHandler = async (event) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const PATCH: RequestHandler = async (event) => {
|
export const PATCH: RequestHandler = async (event) => {
|
||||||
requireAdmin((await event.locals.auth())?.user?.id);
|
requireAdmin((await event.locals.auth())?.user);
|
||||||
const body = await event.request.json();
|
const body = await event.request.json();
|
||||||
const { id, name, country_code } = body as { id?: number; name?: string; country_code?: string };
|
const { id, name, country_code } = body as { id?: number; name?: string; country_code?: string };
|
||||||
if (id == null || !name?.trim() || !country_code?.trim()) {
|
if (id == null || !name?.trim() || !country_code?.trim()) {
|
||||||
@@ -31,7 +31,7 @@ export const PATCH: RequestHandler = async (event) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const DELETE: RequestHandler = async (event) => {
|
export const DELETE: RequestHandler = async (event) => {
|
||||||
requireAdmin((await event.locals.auth())?.user?.id);
|
requireAdmin((await event.locals.auth())?.user);
|
||||||
const id = parseInt(event.url.searchParams.get('id') ?? '');
|
const id = parseInt(event.url.searchParams.get('id') ?? '');
|
||||||
if (isNaN(id)) return json({ error: 'id required' }, { status: 400 });
|
if (isNaN(id)) return json({ error: 'id required' }, { status: 400 });
|
||||||
data.deleteCountry(id);
|
data.deleteCountry(id);
|
||||||
|
|||||||
@@ -4,14 +4,14 @@ import { requireAdmin } from '$lib/server/admin/auth.js';
|
|||||||
import * as data from '$lib/server/admin/data.js';
|
import * as data from '$lib/server/admin/data.js';
|
||||||
|
|
||||||
export const GET: RequestHandler = async (event) => {
|
export const GET: RequestHandler = async (event) => {
|
||||||
requireAdmin((await event.locals.auth())?.user?.id);
|
requireAdmin((await event.locals.auth())?.user);
|
||||||
const tourId = parseInt(event.url.searchParams.get('operator_tour_id') ?? '');
|
const tourId = parseInt(event.url.searchParams.get('operator_tour_id') ?? '');
|
||||||
if (isNaN(tourId)) return json({ error: 'operator_tour_id required' }, { status: 400 });
|
if (isNaN(tourId)) return json({ error: 'operator_tour_id required' }, { status: 400 });
|
||||||
return json(data.listDaysForOperatorTour(tourId));
|
return json(data.listDaysForOperatorTour(tourId));
|
||||||
};
|
};
|
||||||
|
|
||||||
export const POST: RequestHandler = async (event) => {
|
export const POST: RequestHandler = async (event) => {
|
||||||
requireAdmin((await event.locals.auth())?.user?.id);
|
requireAdmin((await event.locals.auth())?.user);
|
||||||
const body = await event.request.json();
|
const body = await event.request.json();
|
||||||
const { operator_tour_id, title, notes } = body as {
|
const { operator_tour_id, title, notes } = body as {
|
||||||
operator_tour_id?: number;
|
operator_tour_id?: number;
|
||||||
@@ -25,7 +25,7 @@ export const POST: RequestHandler = async (event) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const PATCH: RequestHandler = async (event) => {
|
export const PATCH: RequestHandler = async (event) => {
|
||||||
requireAdmin((await event.locals.auth())?.user?.id);
|
requireAdmin((await event.locals.auth())?.user);
|
||||||
const body = await event.request.json();
|
const body = await event.request.json();
|
||||||
const { id, title, notes } = body as { id?: number; title?: string; notes?: string };
|
const { id, title, notes } = body as { id?: number; title?: string; notes?: string };
|
||||||
if (id == null) return json({ error: 'id is required' }, { status: 400 });
|
if (id == null) return json({ error: 'id is required' }, { status: 400 });
|
||||||
@@ -34,7 +34,7 @@ export const PATCH: RequestHandler = async (event) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const DELETE: RequestHandler = async (event) => {
|
export const DELETE: RequestHandler = async (event) => {
|
||||||
requireAdmin((await event.locals.auth())?.user?.id);
|
requireAdmin((await event.locals.auth())?.user);
|
||||||
const id = parseInt(event.url.searchParams.get('id') ?? '');
|
const id = parseInt(event.url.searchParams.get('id') ?? '');
|
||||||
if (isNaN(id)) return json({ error: 'id required' }, { status: 400 });
|
if (isNaN(id)) return json({ error: 'id required' }, { status: 400 });
|
||||||
data.deleteOperatorTourDay(id);
|
data.deleteOperatorTourDay(id);
|
||||||
|
|||||||
@@ -4,14 +4,14 @@ import { requireAdmin } from '$lib/server/admin/auth.js';
|
|||||||
import * as data from '$lib/server/admin/data.js';
|
import * as data from '$lib/server/admin/data.js';
|
||||||
|
|
||||||
export const GET: RequestHandler = async (event) => {
|
export const GET: RequestHandler = async (event) => {
|
||||||
requireAdmin((await event.locals.auth())?.user?.id);
|
requireAdmin((await event.locals.auth())?.user);
|
||||||
const operatorId = parseInt(event.url.searchParams.get('operator_id') ?? '');
|
const operatorId = parseInt(event.url.searchParams.get('operator_id') ?? '');
|
||||||
if (isNaN(operatorId)) return json({ error: 'operator_id required' }, { status: 400 });
|
if (isNaN(operatorId)) return json({ error: 'operator_id required' }, { status: 400 });
|
||||||
return json(data.listToursForOperator(operatorId));
|
return json(data.listToursForOperator(operatorId));
|
||||||
};
|
};
|
||||||
|
|
||||||
export const POST: RequestHandler = async (event) => {
|
export const POST: RequestHandler = async (event) => {
|
||||||
requireAdmin((await event.locals.auth())?.user?.id);
|
requireAdmin((await event.locals.auth())?.user);
|
||||||
const body = await event.request.json();
|
const body = await event.request.json();
|
||||||
const { operator_id, name } = body as { operator_id?: number; name?: string };
|
const { operator_id, name } = body as { operator_id?: number; name?: string };
|
||||||
if (operator_id == null || !name?.trim()) {
|
if (operator_id == null || !name?.trim()) {
|
||||||
@@ -21,7 +21,7 @@ export const POST: RequestHandler = async (event) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const PATCH: RequestHandler = async (event) => {
|
export const PATCH: RequestHandler = async (event) => {
|
||||||
requireAdmin((await event.locals.auth())?.user?.id);
|
requireAdmin((await event.locals.auth())?.user);
|
||||||
const body = await event.request.json();
|
const body = await event.request.json();
|
||||||
const { id, name } = body as { id?: number; name?: string };
|
const { id, name } = body as { id?: number; name?: string };
|
||||||
if (id == null || !name?.trim()) {
|
if (id == null || !name?.trim()) {
|
||||||
@@ -32,7 +32,7 @@ export const PATCH: RequestHandler = async (event) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const DELETE: RequestHandler = async (event) => {
|
export const DELETE: RequestHandler = async (event) => {
|
||||||
requireAdmin((await event.locals.auth())?.user?.id);
|
requireAdmin((await event.locals.auth())?.user);
|
||||||
const id = parseInt(event.url.searchParams.get('id') ?? '');
|
const id = parseInt(event.url.searchParams.get('id') ?? '');
|
||||||
if (isNaN(id)) return json({ error: 'id required' }, { status: 400 });
|
if (isNaN(id)) return json({ error: 'id required' }, { status: 400 });
|
||||||
data.deleteOperatorTour(id);
|
data.deleteOperatorTour(id);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { listTourOperators } from '$lib/server/admin/data.js';
|
|||||||
import { getProviderForOperator } from '$lib/server/admin/providers/index.js';
|
import { getProviderForOperator } from '$lib/server/admin/providers/index.js';
|
||||||
|
|
||||||
export const GET: RequestHandler = async (event) => {
|
export const GET: RequestHandler = async (event) => {
|
||||||
requireAdmin((await event.locals.auth())?.user?.id);
|
requireAdmin((await event.locals.auth())?.user);
|
||||||
|
|
||||||
const operatorId = parseInt(event.url.searchParams.get('operator_id') ?? '');
|
const operatorId = parseInt(event.url.searchParams.get('operator_id') ?? '');
|
||||||
if (isNaN(operatorId)) return json({ error: 'operator_id required' }, { status: 400 });
|
if (isNaN(operatorId)) return json({ error: 'operator_id required' }, { status: 400 });
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ import { requireAdmin } from '$lib/server/admin/auth.js';
|
|||||||
import * as data from '$lib/server/admin/data.js';
|
import * as data from '$lib/server/admin/data.js';
|
||||||
|
|
||||||
export const GET: RequestHandler = async (event) => {
|
export const GET: RequestHandler = async (event) => {
|
||||||
requireAdmin((await event.locals.auth())?.user?.id);
|
requireAdmin((await event.locals.auth())?.user);
|
||||||
const q = event.url.searchParams.get('q') ?? undefined;
|
const q = event.url.searchParams.get('q') ?? undefined;
|
||||||
return json(data.listTourOperators(q));
|
return json(data.listTourOperators(q));
|
||||||
};
|
};
|
||||||
|
|
||||||
export const POST: RequestHandler = async (event) => {
|
export const POST: RequestHandler = async (event) => {
|
||||||
requireAdmin((await event.locals.auth())?.user?.id);
|
requireAdmin((await event.locals.auth())?.user);
|
||||||
const body = await event.request.json();
|
const body = await event.request.json();
|
||||||
const { name, website, highlight_color } = body as {
|
const { name, website, highlight_color } = body as {
|
||||||
name?: string;
|
name?: string;
|
||||||
@@ -24,7 +24,7 @@ export const POST: RequestHandler = async (event) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const PATCH: RequestHandler = async (event) => {
|
export const PATCH: RequestHandler = async (event) => {
|
||||||
requireAdmin((await event.locals.auth())?.user?.id);
|
requireAdmin((await event.locals.auth())?.user);
|
||||||
const body = await event.request.json();
|
const body = await event.request.json();
|
||||||
const { id, name, website, highlight_color } = body as {
|
const { id, name, website, highlight_color } = body as {
|
||||||
id?: number;
|
id?: number;
|
||||||
@@ -40,7 +40,7 @@ export const PATCH: RequestHandler = async (event) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const DELETE: RequestHandler = async (event) => {
|
export const DELETE: RequestHandler = async (event) => {
|
||||||
requireAdmin((await event.locals.auth())?.user?.id);
|
requireAdmin((await event.locals.auth())?.user);
|
||||||
const id = parseInt(event.url.searchParams.get('id') ?? '');
|
const id = parseInt(event.url.searchParams.get('id') ?? '');
|
||||||
if (isNaN(id)) return json({ error: 'id required' }, { status: 400 });
|
if (isNaN(id)) return json({ error: 'id required' }, { status: 400 });
|
||||||
data.deleteTourOperator(id);
|
data.deleteTourOperator(id);
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ import { requireAdmin } from '$lib/server/admin/auth.js';
|
|||||||
import { deleteUser, listUsers, updateUser } from '$lib/server/users.js';
|
import { deleteUser, listUsers, updateUser } from '$lib/server/users.js';
|
||||||
|
|
||||||
export const GET: RequestHandler = async (event) => {
|
export const GET: RequestHandler = async (event) => {
|
||||||
requireAdmin((await event.locals.auth())?.user?.id);
|
requireAdmin((await event.locals.auth())?.user);
|
||||||
return json(listUsers());
|
return json(listUsers());
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PATCH: RequestHandler = async (event) => {
|
export const PATCH: RequestHandler = async (event) => {
|
||||||
requireAdmin((await event.locals.auth())?.user?.id);
|
requireAdmin((await event.locals.auth())?.user);
|
||||||
const body = await event.request.json();
|
const body = await event.request.json();
|
||||||
const { id, username, full_name, email } = body as {
|
const { id, username, full_name, email } = body as {
|
||||||
id?: string;
|
id?: string;
|
||||||
@@ -25,7 +25,7 @@ export const PATCH: RequestHandler = async (event) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const DELETE: RequestHandler = async (event) => {
|
export const DELETE: RequestHandler = async (event) => {
|
||||||
requireAdmin((await event.locals.auth())?.user?.id);
|
requireAdmin((await event.locals.auth())?.user);
|
||||||
const id = event.url.searchParams.get('id')?.trim();
|
const id = event.url.searchParams.get('id')?.trim();
|
||||||
if (!id) return json({ error: 'id required' }, { status: 400 });
|
if (!id) return json({ error: 'id required' }, { status: 400 });
|
||||||
deleteUser(id);
|
deleteUser(id);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { getProviderForOperator } from '$lib/server/admin/providers/index.js';
|
|||||||
import type { PageServerLoad, Actions } from './$types';
|
import type { PageServerLoad, Actions } from './$types';
|
||||||
|
|
||||||
export const load: PageServerLoad = async (event) => {
|
export const load: PageServerLoad = async (event) => {
|
||||||
requireAdmin((await event.locals.auth())?.user?.id);
|
requireAdmin((await event.locals.auth())?.user);
|
||||||
|
|
||||||
const operatorId = parseInt(event.params.operatorId);
|
const operatorId = parseInt(event.params.operatorId);
|
||||||
if (isNaN(operatorId)) error(404, 'Not found');
|
if (isNaN(operatorId)) error(404, 'Not found');
|
||||||
@@ -27,7 +27,7 @@ export const load: PageServerLoad = async (event) => {
|
|||||||
|
|
||||||
export const actions: Actions = {
|
export const actions: Actions = {
|
||||||
addTour: async (event) => {
|
addTour: async (event) => {
|
||||||
requireAdmin((await event.locals.auth())?.user?.id);
|
requireAdmin((await event.locals.auth())?.user);
|
||||||
|
|
||||||
const operatorId = parseInt(event.params.operatorId);
|
const operatorId = parseInt(event.params.operatorId);
|
||||||
if (isNaN(operatorId)) return fail(400, { error: 'Invalid operator ID' });
|
if (isNaN(operatorId)) return fail(400, { error: 'Invalid operator ID' });
|
||||||
@@ -45,7 +45,7 @@ export const actions: Actions = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
editTour: async (event) => {
|
editTour: async (event) => {
|
||||||
requireAdmin((await event.locals.auth())?.user?.id);
|
requireAdmin((await event.locals.auth())?.user);
|
||||||
|
|
||||||
const formData = await event.request.formData();
|
const formData = await event.request.formData();
|
||||||
const id = parseInt(formData.get('id') as string);
|
const id = parseInt(formData.get('id') as string);
|
||||||
@@ -63,7 +63,7 @@ export const actions: Actions = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
deleteTour: async (event) => {
|
deleteTour: async (event) => {
|
||||||
requireAdmin((await event.locals.auth())?.user?.id);
|
requireAdmin((await event.locals.auth())?.user);
|
||||||
|
|
||||||
const formData = await event.request.formData();
|
const formData = await event.request.formData();
|
||||||
const id = parseInt(formData.get('id') as string);
|
const id = parseInt(formData.get('id') as string);
|
||||||
@@ -78,7 +78,7 @@ export const actions: Actions = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
importTour: async (event) => {
|
importTour: async (event) => {
|
||||||
requireAdmin((await event.locals.auth())?.user?.id);
|
requireAdmin((await event.locals.auth())?.user);
|
||||||
|
|
||||||
const operatorId = parseInt(event.params.operatorId);
|
const operatorId = parseInt(event.params.operatorId);
|
||||||
if (isNaN(operatorId)) return fail(400, { error: 'Invalid operator ID' });
|
if (isNaN(operatorId)) return fail(400, { error: 'Invalid operator ID' });
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import * as data from '$lib/server/admin/data.js';
|
|||||||
import type { PageServerLoad, Actions } from './$types';
|
import type { PageServerLoad, Actions } from './$types';
|
||||||
|
|
||||||
export const load: PageServerLoad = async (event) => {
|
export const load: PageServerLoad = async (event) => {
|
||||||
requireAdmin((await event.locals.auth())?.user?.id);
|
requireAdmin((await event.locals.auth())?.user);
|
||||||
|
|
||||||
const operatorId = parseInt(event.params.operatorId);
|
const operatorId = parseInt(event.params.operatorId);
|
||||||
const tourId = parseInt(event.params.tourId);
|
const tourId = parseInt(event.params.tourId);
|
||||||
@@ -27,7 +27,7 @@ export const load: PageServerLoad = async (event) => {
|
|||||||
|
|
||||||
export const actions: Actions = {
|
export const actions: Actions = {
|
||||||
addDay: async (event) => {
|
addDay: async (event) => {
|
||||||
requireAdmin((await event.locals.auth())?.user?.id);
|
requireAdmin((await event.locals.auth())?.user);
|
||||||
|
|
||||||
const tourId = parseInt(event.params.tourId);
|
const tourId = parseInt(event.params.tourId);
|
||||||
if (isNaN(tourId)) return fail(400, { error: 'Invalid tour ID' });
|
if (isNaN(tourId)) return fail(400, { error: 'Invalid tour ID' });
|
||||||
@@ -45,7 +45,7 @@ export const actions: Actions = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
editDay: async (event) => {
|
editDay: async (event) => {
|
||||||
requireAdmin((await event.locals.auth())?.user?.id);
|
requireAdmin((await event.locals.auth())?.user);
|
||||||
|
|
||||||
const formData = await event.request.formData();
|
const formData = await event.request.formData();
|
||||||
const id = parseInt(formData.get('id') as string);
|
const id = parseInt(formData.get('id') as string);
|
||||||
@@ -63,7 +63,7 @@ export const actions: Actions = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
deleteDay: async (event) => {
|
deleteDay: async (event) => {
|
||||||
requireAdmin((await event.locals.auth())?.user?.id);
|
requireAdmin((await event.locals.auth())?.user);
|
||||||
|
|
||||||
const formData = await event.request.formData();
|
const formData = await event.request.formData();
|
||||||
const id = parseInt(formData.get('id') as string);
|
const id = parseInt(formData.get('id') as string);
|
||||||
@@ -78,7 +78,7 @@ export const actions: Actions = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
addDayPlan: async (event) => {
|
addDayPlan: async (event) => {
|
||||||
requireAdmin((await event.locals.auth())?.user?.id);
|
requireAdmin((await event.locals.auth())?.user);
|
||||||
|
|
||||||
const formData = await event.request.formData();
|
const formData = await event.request.formData();
|
||||||
const dayId = parseInt(formData.get('dayId') as string);
|
const dayId = parseInt(formData.get('dayId') as string);
|
||||||
@@ -247,7 +247,7 @@ export const actions: Actions = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
editDayPlan: async (event) => {
|
editDayPlan: async (event) => {
|
||||||
requireAdmin((await event.locals.auth())?.user?.id);
|
requireAdmin((await event.locals.auth())?.user);
|
||||||
|
|
||||||
const formData = await event.request.formData();
|
const formData = await event.request.formData();
|
||||||
const id = parseInt(formData.get('id') as string);
|
const id = parseInt(formData.get('id') as string);
|
||||||
@@ -310,7 +310,7 @@ export const actions: Actions = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
deleteDayPlan: async (event) => {
|
deleteDayPlan: async (event) => {
|
||||||
requireAdmin((await event.locals.auth())?.user?.id);
|
requireAdmin((await event.locals.auth())?.user);
|
||||||
|
|
||||||
const formData = await event.request.formData();
|
const formData = await event.request.formData();
|
||||||
const id = parseInt(formData.get('id') as string);
|
const id = parseInt(formData.get('id') as string);
|
||||||
|
|||||||
@@ -1,8 +1,20 @@
|
|||||||
import { env } from '$env/dynamic/private';
|
import { env } from '$env/dynamic/private';
|
||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
export const load: PageServerLoad = async () => {
|
const resolveErrorMessage = (value: string | null): string | null => {
|
||||||
|
if (!value) return null;
|
||||||
|
if (value === 'CredentialsSignin') return 'Invalid credentials';
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ url }) => {
|
||||||
|
const authUrl = process.env.AUTH_URL ?? env.AUTH_URL ?? '';
|
||||||
|
const localAuthEnabled = (process.env.LOCAL_AUTH_ENABLED ?? env.LOCAL_AUTH_ENABLED) === 'true';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
signinUrl: `${env.AUTH_URL}/signin/synology`
|
signinUrl: `${authUrl}/signin/synology`,
|
||||||
|
localSigninUrl: `${authUrl}/signin/local`,
|
||||||
|
localAuthEnabled,
|
||||||
|
error: resolveErrorMessage(url.searchParams.get('error'))
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,22 +1,85 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
let form: HTMLFormElement;
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
form.submit();
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>Sign in — Trips</title>
|
<title>Sign in — Trips</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<form bind:this={form} method="POST" action={data.signinUrl} class="hidden">
|
<div class="mx-auto flex min-h-[70vh] w-full max-w-3xl flex-col justify-center px-6 py-12">
|
||||||
<input type="hidden" name="csrfToken" />
|
<div class="mb-10">
|
||||||
</form>
|
<h1 class="text-3xl font-semibold text-slate-900">Sign in</h1>
|
||||||
|
<p class="mt-2 text-sm text-slate-500">
|
||||||
|
Choose a sign-in method to access Trips.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex min-h-[60vh] items-center justify-center">
|
{#if data.error}
|
||||||
<p class="text-gray-500">Redirecting to sign in…</p>
|
<div class="mb-6 rounded-lg border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">
|
||||||
|
{data.error}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="grid gap-8 md:grid-cols-2">
|
||||||
|
<section class="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||||
|
<h2 class="text-lg font-semibold text-slate-900">Synology account</h2>
|
||||||
|
<p class="mt-2 text-sm text-slate-500">
|
||||||
|
Use your Synology SSO account.
|
||||||
|
</p>
|
||||||
|
<form method="POST" action={data.signinUrl} class="mt-6">
|
||||||
|
<input type="hidden" name="csrfToken" />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="inline-flex w-full items-center justify-center rounded-lg bg-slate-900 px-4 py-2 text-sm font-semibold text-white transition hover:bg-slate-800"
|
||||||
|
>
|
||||||
|
Sign in with Synology
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||||
|
<h2 class="text-lg font-semibold text-slate-900">Local account</h2>
|
||||||
|
<p class="mt-2 text-sm text-slate-500">
|
||||||
|
Sign in with your local username or email.
|
||||||
|
</p>
|
||||||
|
{#if data.localAuthEnabled}
|
||||||
|
<form method="POST" action={data.localSigninUrl} class="mt-6 space-y-4">
|
||||||
|
<input type="hidden" name="csrfToken" />
|
||||||
|
<div>
|
||||||
|
<label class="text-sm font-medium text-slate-700" for="identifier">
|
||||||
|
Username or email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="identifier"
|
||||||
|
name="identifier"
|
||||||
|
autocomplete="username"
|
||||||
|
class="mt-2 w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 shadow-sm focus:border-slate-400 focus:outline-none"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-sm font-medium text-slate-700" for="password">Password</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
autocomplete="current-password"
|
||||||
|
type="password"
|
||||||
|
class="mt-2 w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 shadow-sm focus:border-slate-400 focus:outline-none"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="inline-flex w-full items-center justify-center rounded-lg border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-900 transition hover:border-slate-400"
|
||||||
|
>
|
||||||
|
Sign in locally
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{:else}
|
||||||
|
<div class="mt-6 rounded-lg border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-500">
|
||||||
|
Local sign-in is disabled.
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
37
src/routes/login/page.server.test.ts
Normal file
37
src/routes/login/page.server.test.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { load } from './+page.server';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.AUTH_URL = 'https://example.com/auth';
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('login page load', () => {
|
||||||
|
it('returns auth options when local auth is enabled', async () => {
|
||||||
|
process.env.LOCAL_AUTH_ENABLED = 'true';
|
||||||
|
const result = await load({
|
||||||
|
url: new URL('https://example.com/login')
|
||||||
|
} as Parameters<typeof load>[0]);
|
||||||
|
|
||||||
|
expect(result.signinUrl).toBe('https://example.com/auth/signin/synology');
|
||||||
|
expect(result.localSigninUrl).toBe('https://example.com/auth/signin/local');
|
||||||
|
expect(result.localAuthEnabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps credentials errors to a generic message', async () => {
|
||||||
|
process.env.LOCAL_AUTH_ENABLED = 'true';
|
||||||
|
const result = await load({
|
||||||
|
url: new URL('https://example.com/login?error=CredentialsSignin')
|
||||||
|
} as Parameters<typeof load>[0]);
|
||||||
|
|
||||||
|
expect(result.error).toBe('Invalid credentials');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables local auth in the response when disabled', async () => {
|
||||||
|
process.env.LOCAL_AUTH_ENABLED = 'false';
|
||||||
|
const result = await load({
|
||||||
|
url: new URL('https://example.com/login')
|
||||||
|
} as Parameters<typeof load>[0]);
|
||||||
|
|
||||||
|
expect(result.localAuthEnabled).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user