Add local authentication option #37
@@ -10,6 +10,15 @@ AUTH_URL=https://cloud.campbellwireless.net/trips/auth
|
||||
# Auth.js secret — generate with: openssl rand -base64 32
|
||||
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_USER_IDS=
|
||||
|
||||
|
||||
13
README.md
13
README.md
@@ -43,6 +43,19 @@ bun install
|
||||
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
|
||||
|
||||
```sh
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
"vitest": "^4.0.18"
|
||||
},
|
||||
"dependencies": {
|
||||
"@auth/sveltekit": "^1.11.1"
|
||||
"@auth/sveltekit": "^1.11.1",
|
||||
"argon2": "^0.41.1"
|
||||
}
|
||||
}
|
||||
|
||||
32
src/auth.ts
32
src/auth.ts
@@ -1,6 +1,8 @@
|
||||
import { SvelteKitAuth } from '@auth/sveltekit';
|
||||
import Credentials from '@auth/core/providers/credentials';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { upsertUserFromAuth } from '$lib/server/users.js';
|
||||
import { verifyLocalCredentials } from '$lib/server/local-auth.js';
|
||||
|
||||
export const { handle, signIn, signOut } = SvelteKitAuth({
|
||||
providers: [
|
||||
@@ -19,11 +21,34 @@ export const { handle, signIn, signOut } = SvelteKitAuth({
|
||||
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,
|
||||
callbacks: {
|
||||
jwt({ token, profile }) {
|
||||
jwt({ token, profile, user }) {
|
||||
const details = profile as
|
||||
| {
|
||||
sub?: string;
|
||||
@@ -43,6 +68,9 @@ export const { handle, signIn, signOut } = SvelteKitAuth({
|
||||
authSource: 'OIDC - Synology'
|
||||
});
|
||||
}
|
||||
if (user?.id) {
|
||||
token.sub = user.id as string;
|
||||
}
|
||||
return token;
|
||||
},
|
||||
session({ session, token }) {
|
||||
|
||||
@@ -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(`
|
||||
CREATE TABLE IF NOT EXISTS cities (
|
||||
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');
|
||||
});
|
||||
});
|
||||
146
src/lib/server/local-auth.ts
Normal file
146
src/lib/server/local-auth.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
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 getNumberEnv = (value: string | undefined, fallback: number): number => {
|
||||
const parsed = Number.parseInt(value ?? '', 10);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
};
|
||||
|
||||
const getLocalAuthConfig = () => {
|
||||
return {
|
||||
enabled: env.LOCAL_AUTH_ENABLED === 'true',
|
||||
maxAttempts: getNumberEnv(env.LOCAL_AUTH_MAX_ATTEMPTS, 5),
|
||||
windowMs: getNumberEnv(env.LOCAL_AUTH_WINDOW_SECONDS, 900) * 1000,
|
||||
lockoutMs: getNumberEnv(env.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]
|
||||
);
|
||||
}
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,17 @@
|
||||
import { env } from '$env/dynamic/private';
|
||||
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 }) => {
|
||||
return {
|
||||
signinUrl: `${env.AUTH_URL}/signin/synology`
|
||||
signinUrl: `${env.AUTH_URL}/signin/synology`,
|
||||
localSigninUrl: `${env.AUTH_URL}/signin/local`,
|
||||
localAuthEnabled: env.LOCAL_AUTH_ENABLED === 'true',
|
||||
error: resolveErrorMessage(url.searchParams.get('error'))
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,22 +1,85 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let { data } = $props();
|
||||
let form: HTMLFormElement;
|
||||
|
||||
onMount(() => {
|
||||
form.submit();
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Sign in — Trips</title>
|
||||
</svelte:head>
|
||||
|
||||
<form bind:this={form} method="POST" action={data.signinUrl} class="hidden">
|
||||
<input type="hidden" name="csrfToken" />
|
||||
</form>
|
||||
<div class="mx-auto flex min-h-[70vh] w-full max-w-3xl flex-col justify-center px-6 py-12">
|
||||
<div class="mb-10">
|
||||
<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">
|
||||
<p class="text-gray-500">Redirecting to sign in…</p>
|
||||
{#if data.error}
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user