feat(phase2): OAuth/Authentik integration + drag-and-drop reordering
- Add OIDC/OAuth2 login via openid-client with PKCE flow - Auto-provision OAuth users with group mapping - Conditional login page (OAuth/local/both based on auth mode) - Admin OAuth test connection button - Install svelte-dnd-action for board editor DnD - Draggable sections and widgets with cross-section moves - Reorder APIs with atomic Prisma transactions - Visual drag handles and drop zone indicators
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types.js';
|
||||
import { requireAdmin } from '$lib/server/middleware/authorize.js';
|
||||
import { testConnection, invalidateOAuthCache } from '$lib/server/services/oauthService.js';
|
||||
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
requireAdmin(event);
|
||||
|
||||
try {
|
||||
// Invalidate cache so we test with current settings
|
||||
invalidateOAuthCache();
|
||||
const issuer = await testConnection();
|
||||
return json({ success: true, issuer });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'OAuth connection test failed';
|
||||
return json({ success: false, error: message }, { status: 400 });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import * as boardService from '$lib/server/services/boardService.js';
|
||||
import * as permissionService from '$lib/server/services/permissionService.js';
|
||||
import { success, error } from '$lib/server/utils/response.js';
|
||||
import { EntityType, PermissionLevel, UserRole } from '$lib/utils/constants.js';
|
||||
|
||||
/**
|
||||
* PUT /api/boards/:id/reorder — Reorder sections within a board.
|
||||
* Body: { sectionIds: string[] }
|
||||
*/
|
||||
export const PUT: RequestHandler = async (event) => {
|
||||
const user = event.locals.user;
|
||||
if (!user) {
|
||||
return json(error('Authentication required'), { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = event.params;
|
||||
|
||||
if (user.role !== UserRole.ADMIN) {
|
||||
const result = await permissionService.checkPermission(
|
||||
EntityType.BOARD,
|
||||
id,
|
||||
user.id,
|
||||
PermissionLevel.EDIT
|
||||
);
|
||||
if (!result.hasPermission) {
|
||||
return json(error('Insufficient permissions'), { status: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await event.request.json();
|
||||
} catch {
|
||||
return json(error('Invalid JSON body'), { status: 400 });
|
||||
}
|
||||
|
||||
const { sectionIds } = body as { sectionIds?: string[] };
|
||||
if (!Array.isArray(sectionIds) || sectionIds.length === 0) {
|
||||
return json(error('sectionIds must be a non-empty array of strings'), { status: 400 });
|
||||
}
|
||||
|
||||
if (!sectionIds.every((sid) => typeof sid === 'string')) {
|
||||
return json(error('All sectionIds must be strings'), { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
await boardService.reorderSections(id, sectionIds);
|
||||
return json(success(null));
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to reorder sections';
|
||||
const status = message.includes('not found') ? 404 : 500;
|
||||
return json(error(message), { status });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import * as boardService from '$lib/server/services/boardService.js';
|
||||
import * as permissionService from '$lib/server/services/permissionService.js';
|
||||
import { success, error } from '$lib/server/utils/response.js';
|
||||
import { EntityType, PermissionLevel, UserRole } from '$lib/utils/constants.js';
|
||||
|
||||
/**
|
||||
* PUT /api/boards/:id/sections/:sid/reorder — Reorder widgets within a section.
|
||||
* Body: { widgetIds: string[] }
|
||||
*/
|
||||
export const PUT: RequestHandler = async (event) => {
|
||||
const user = event.locals.user;
|
||||
if (!user) {
|
||||
return json(error('Authentication required'), { status: 401 });
|
||||
}
|
||||
|
||||
const { id, sid } = event.params;
|
||||
|
||||
if (user.role !== UserRole.ADMIN) {
|
||||
const result = await permissionService.checkPermission(
|
||||
EntityType.BOARD,
|
||||
id,
|
||||
user.id,
|
||||
PermissionLevel.EDIT
|
||||
);
|
||||
if (!result.hasPermission) {
|
||||
return json(error('Insufficient permissions'), { status: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await event.request.json();
|
||||
} catch {
|
||||
return json(error('Invalid JSON body'), { status: 400 });
|
||||
}
|
||||
|
||||
const { widgetIds } = body as { widgetIds?: string[] };
|
||||
if (!Array.isArray(widgetIds)) {
|
||||
return json(error('widgetIds must be an array of strings'), { status: 400 });
|
||||
}
|
||||
|
||||
if (!widgetIds.every((wid) => typeof wid === 'string')) {
|
||||
return json(error('All widgetIds must be strings'), { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
await boardService.reorderWidgets(sid, widgetIds);
|
||||
return json(success(null));
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to reorder widgets';
|
||||
const status = message.includes('not found') ? 404 : 500;
|
||||
return json(error(message), { status });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import { redirect, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types.js';
|
||||
import * as oauthService from '$lib/server/services/oauthService.js';
|
||||
|
||||
const COOKIE_BASE = {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax' as const,
|
||||
path: '/'
|
||||
};
|
||||
|
||||
export const GET: RequestHandler = async ({ cookies, url }) => {
|
||||
try {
|
||||
const appUrl = process.env.APP_URL || url.origin;
|
||||
const redirectUri = process.env.OAUTH_REDIRECT_URI || `${appUrl}/auth/oauth/callback`;
|
||||
|
||||
// Generate PKCE values
|
||||
const codeVerifier = oauthService.generateCodeVerifier();
|
||||
const codeChallenge = await oauthService.calculateCodeChallenge(codeVerifier);
|
||||
|
||||
// Store code_verifier in HTTP-only cookie for the callback
|
||||
cookies.set('oauth_code_verifier', codeVerifier, {
|
||||
...COOKIE_BASE,
|
||||
maxAge: 600 // 10 minutes — enough for the auth flow
|
||||
});
|
||||
|
||||
// Build authorization URL and redirect
|
||||
const authUrl = await oauthService.generateAuthUrl(redirectUri, codeChallenge);
|
||||
|
||||
throw redirect(302, authUrl);
|
||||
} catch (err) {
|
||||
// Re-throw redirects
|
||||
if (err && typeof err === 'object' && 'status' in err && (err as { status: number }).status === 302) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
const message = err instanceof Error ? err.message : 'Failed to initiate OAuth login';
|
||||
throw error(500, message);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,82 @@
|
||||
import { redirect, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types.js';
|
||||
import * as oauthService from '$lib/server/services/oauthService.js';
|
||||
import * as userService from '$lib/server/services/userService.js';
|
||||
import * as authService from '$lib/server/services/authService.js';
|
||||
|
||||
const COOKIE_BASE = {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax' as const,
|
||||
path: '/'
|
||||
};
|
||||
|
||||
export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
try {
|
||||
// Check for error response from the provider
|
||||
const oauthError = url.searchParams.get('error');
|
||||
if (oauthError) {
|
||||
const description = url.searchParams.get('error_description') || oauthError;
|
||||
throw new Error(`OAuth provider returned an error: ${description}`);
|
||||
}
|
||||
|
||||
// Ensure we have an authorization code
|
||||
const code = url.searchParams.get('code');
|
||||
if (!code) {
|
||||
throw new Error('No authorization code received from OAuth provider');
|
||||
}
|
||||
|
||||
// Retrieve the code_verifier from the cookie
|
||||
const codeVerifier = cookies.get('oauth_code_verifier');
|
||||
if (!codeVerifier) {
|
||||
throw new Error('OAuth session expired. Please try logging in again.');
|
||||
}
|
||||
|
||||
// Clear the code_verifier cookie
|
||||
cookies.delete('oauth_code_verifier', { path: '/' });
|
||||
|
||||
// Exchange the authorization code for tokens and get user info
|
||||
const userInfo = await oauthService.handleCallback(url, codeVerifier);
|
||||
|
||||
// Find or create local user from OAuth info
|
||||
const user = await userService.findOrCreateByOAuth({
|
||||
email: userInfo.email,
|
||||
displayName: userInfo.name || userInfo.preferred_username || userInfo.email.split('@')[0],
|
||||
avatarUrl: userInfo.picture,
|
||||
groups: userInfo.groups ? [...userInfo.groups] : undefined
|
||||
});
|
||||
|
||||
// Issue local JWT tokens (same as local auth flow)
|
||||
const accessToken = authService.signAccessToken({
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
role: user.role
|
||||
});
|
||||
const refreshToken = authService.generateRefreshToken();
|
||||
await authService.saveRefreshToken(user.id, refreshToken);
|
||||
|
||||
// Set session cookies
|
||||
cookies.set('access_token', accessToken, {
|
||||
...COOKIE_BASE,
|
||||
maxAge: 900 // 15 minutes
|
||||
});
|
||||
cookies.set('refresh_token', refreshToken, {
|
||||
...COOKIE_BASE,
|
||||
maxAge: 604800 // 7 days
|
||||
});
|
||||
cookies.set('refresh_user_id', user.id, {
|
||||
...COOKIE_BASE,
|
||||
maxAge: 604800 // 7 days
|
||||
});
|
||||
|
||||
throw redirect(302, '/');
|
||||
} catch (err) {
|
||||
// Re-throw redirects
|
||||
if (err && typeof err === 'object' && 'status' in err && (err as { status: number }).status === 302) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
const message = err instanceof Error ? err.message : 'OAuth authentication failed';
|
||||
throw error(500, message);
|
||||
}
|
||||
};
|
||||
@@ -1,11 +1,65 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types.js';
|
||||
import { enhance } from '$app/forms';
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import DraggableBoard from '$lib/components/board/DraggableBoard.svelte';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
let showAddSection = $state(false);
|
||||
let addWidgetSectionId = $state<string | null>(null);
|
||||
|
||||
function handleToggleAddWidget(sectionId: string) {
|
||||
addWidgetSectionId = addWidgetSectionId === sectionId ? null : sectionId;
|
||||
}
|
||||
|
||||
async function handleDeleteSection(sectionId: string) {
|
||||
const formData = new FormData();
|
||||
formData.set('sectionId', sectionId);
|
||||
|
||||
try {
|
||||
await fetch(`?/deleteSection`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
await invalidateAll();
|
||||
} catch (err) {
|
||||
console.error('Failed to delete section:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddWidget(sectionId: string, appId: string) {
|
||||
const formData = new FormData();
|
||||
formData.set('sectionId', sectionId);
|
||||
formData.set('type', 'app');
|
||||
formData.set('appId', appId);
|
||||
|
||||
try {
|
||||
await fetch(`?/addWidget`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
addWidgetSectionId = null;
|
||||
await invalidateAll();
|
||||
} catch (err) {
|
||||
console.error('Failed to add widget:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteWidget(widgetId: string) {
|
||||
const formData = new FormData();
|
||||
formData.set('widgetId', widgetId);
|
||||
|
||||
try {
|
||||
await fetch(`?/deleteWidget`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
await invalidateAll();
|
||||
} catch (err) {
|
||||
console.error('Failed to delete widget:', err);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -92,7 +146,7 @@
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<!-- Sections -->
|
||||
<!-- Sections with Drag-and-Drop -->
|
||||
<section class="mb-8">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-foreground">Sections</h2>
|
||||
@@ -151,115 +205,16 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if data.board.sections.length === 0}
|
||||
<div class="rounded-xl border border-border bg-card/50 p-8 text-center">
|
||||
<p class="text-muted-foreground">No sections yet. Add one to get started.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-4">
|
||||
{#each data.board.sections as section (section.id)}
|
||||
<div class="rounded-xl border border-border bg-card p-4 shadow-sm">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium text-foreground">{section.title}</span>
|
||||
<span class="text-xs text-muted-foreground">Order: {section.order}</span>
|
||||
{#if section.icon}
|
||||
<span class="text-xs text-muted-foreground">({section.icon})</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (addWidgetSectionId = addWidgetSectionId === section.id ? null : section.id)}
|
||||
class="rounded-md bg-primary px-2 py-1 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
>
|
||||
Add Widget
|
||||
</button>
|
||||
<form method="POST" action="?/deleteSection" use:enhance>
|
||||
<input type="hidden" name="sectionId" value={section.id} />
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-md bg-destructive px-2 py-1 text-xs font-medium text-destructive-foreground transition-colors hover:bg-destructive/90"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if addWidgetSectionId === section.id}
|
||||
<div class="mb-3 rounded-lg border border-border bg-muted/50 p-3">
|
||||
<form
|
||||
method="POST"
|
||||
action="?/addWidget"
|
||||
use:enhance={() => {
|
||||
return async ({ update }) => {
|
||||
await update();
|
||||
addWidgetSectionId = null;
|
||||
};
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="sectionId" value={section.id} />
|
||||
<input type="hidden" name="type" value="app" />
|
||||
<div>
|
||||
<label for="widget-app-{section.id}" class="mb-1 block text-sm font-medium text-foreground">Select App</label>
|
||||
<select
|
||||
id="widget-app-{section.id}"
|
||||
name="appId"
|
||||
class="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30"
|
||||
required
|
||||
>
|
||||
<option value="">Choose an app...</option>
|
||||
{#each data.apps as app (app.id)}
|
||||
<option value={app.id}>{app.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-md bg-primary px-2 py-1 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Widgets list -->
|
||||
{#if section.widgets.length === 0}
|
||||
<p class="text-sm text-muted-foreground">No widgets in this section.</p>
|
||||
{:else}
|
||||
<div class="space-y-2">
|
||||
{#each section.widgets as widget (widget.id)}
|
||||
<div class="flex items-center justify-between rounded-lg border border-border bg-background/50 px-3 py-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs font-medium uppercase text-primary">{widget.type}</span>
|
||||
{#if widget.app}
|
||||
<span class="text-sm text-foreground">{widget.app.name}</span>
|
||||
<span class="text-xs text-muted-foreground">({widget.app.url})</span>
|
||||
{:else}
|
||||
<span class="text-sm text-muted-foreground">Widget #{widget.order}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<form method="POST" action="?/deleteWidget" use:enhance>
|
||||
<input type="hidden" name="widgetId" value={widget.id} />
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-md bg-destructive px-2 py-1 text-xs font-medium text-destructive-foreground transition-colors hover:bg-destructive/90"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<DraggableBoard
|
||||
boardId={data.board.id}
|
||||
sections={data.board.sections}
|
||||
apps={data.apps}
|
||||
{addWidgetSectionId}
|
||||
onToggleAddWidget={handleToggleAddWidget}
|
||||
onDeleteSection={handleDeleteSection}
|
||||
onAddWidget={handleAddWidget}
|
||||
onDeleteWidget={handleDeleteWidget}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,9 @@ import { fail, redirect } from '@sveltejs/kit';
|
||||
import { loginSchema } from '$lib/utils/validators.js';
|
||||
import * as userService from '$lib/server/services/userService.js';
|
||||
import * as authService from '$lib/server/services/authService.js';
|
||||
import { prisma } from '$lib/server/prisma.js';
|
||||
import { DEFAULTS } from '$lib/utils/constants.js';
|
||||
import type { AuthMode } from '$lib/utils/constants.js';
|
||||
|
||||
const COOKIE_BASE = {
|
||||
httpOnly: true,
|
||||
@@ -19,8 +22,15 @@ export const load: PageServerLoad = async ({ locals }) => {
|
||||
throw redirect(302, '/');
|
||||
}
|
||||
|
||||
// Load auth mode from SystemSettings
|
||||
const settings = await prisma.systemSettings.findUnique({
|
||||
where: { id: DEFAULTS.SYSTEM_SETTINGS_ID },
|
||||
select: { authMode: true }
|
||||
});
|
||||
const authMode: AuthMode = (settings?.authMode as AuthMode) || 'local';
|
||||
|
||||
const form = await superValidate(zod(loginSchema));
|
||||
return { form };
|
||||
return { form, authMode };
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
const { form, errors, enhance, submitting } = superForm(data.form);
|
||||
|
||||
const showLocalForm = data.authMode === 'local' || data.authMode === 'both';
|
||||
const showOAuthButton = data.authMode === 'oauth' || data.authMode === 'both';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -38,62 +41,91 @@
|
||||
<p class="mt-1 text-sm text-muted-foreground">Sign in to your account</p>
|
||||
</div>
|
||||
|
||||
<form method="POST" use:enhance class="space-y-4">
|
||||
<div>
|
||||
<label for="email" class="mb-1 block text-sm font-medium text-card-foreground">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autocomplete="email"
|
||||
bind:value={$form.email}
|
||||
class="w-full rounded-lg border border-input bg-background px-3 py-2.5 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30"
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
{#if $errors.email}
|
||||
<p class="mt-1 text-sm text-destructive">{$errors.email[0]}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password" class="mb-1 block text-sm font-medium text-card-foreground">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
bind:value={$form.password}
|
||||
class="w-full rounded-lg border border-input bg-background px-3 py-2.5 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30"
|
||||
placeholder="Enter your password"
|
||||
/>
|
||||
{#if $errors.password}
|
||||
<p class="mt-1 text-sm text-destructive">{$errors.password[0]}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={$submitting}
|
||||
class="w-full rounded-lg bg-primary px-4 py-2.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
{#if showOAuthButton}
|
||||
<a
|
||||
href="/auth/oauth/authorize"
|
||||
class="flex w-full items-center justify-center gap-2 rounded-lg border border-border bg-background px-4 py-2.5 text-sm font-medium text-foreground transition-colors hover:bg-muted focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
{#if $submitting}
|
||||
<span class="flex items-center justify-center gap-2">
|
||||
<span class="h-4 w-4 animate-spin rounded-full border-2 border-primary-foreground border-t-transparent"></span>
|
||||
Signing in...
|
||||
</span>
|
||||
{:else}
|
||||
Sign In
|
||||
{/if}
|
||||
</button>
|
||||
</form>
|
||||
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4" />
|
||||
<polyline points="10 17 15 12 10 7" />
|
||||
<line x1="15" y1="12" x2="3" y2="12" />
|
||||
</svg>
|
||||
Sign in with OAuth
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
<p class="mt-6 text-center text-sm text-muted-foreground">
|
||||
Don't have an account?
|
||||
<a href="/register" class="font-medium text-primary hover:underline">Register</a>
|
||||
</p>
|
||||
{#if showOAuthButton && showLocalForm}
|
||||
<div class="relative my-4">
|
||||
<div class="absolute inset-0 flex items-center">
|
||||
<div class="w-full border-t border-border"></div>
|
||||
</div>
|
||||
<div class="relative flex justify-center text-xs uppercase">
|
||||
<span class="bg-card px-2 text-muted-foreground">or</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showLocalForm}
|
||||
<form method="POST" use:enhance class="space-y-4">
|
||||
<div>
|
||||
<label for="email" class="mb-1 block text-sm font-medium text-card-foreground">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autocomplete="email"
|
||||
bind:value={$form.email}
|
||||
class="w-full rounded-lg border border-input bg-background px-3 py-2.5 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30"
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
{#if $errors.email}
|
||||
<p class="mt-1 text-sm text-destructive">{$errors.email[0]}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password" class="mb-1 block text-sm font-medium text-card-foreground">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
bind:value={$form.password}
|
||||
class="w-full rounded-lg border border-input bg-background px-3 py-2.5 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30"
|
||||
placeholder="Enter your password"
|
||||
/>
|
||||
{#if $errors.password}
|
||||
<p class="mt-1 text-sm text-destructive">{$errors.password[0]}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={$submitting}
|
||||
class="w-full rounded-lg bg-primary px-4 py-2.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{#if $submitting}
|
||||
<span class="flex items-center justify-center gap-2">
|
||||
<span class="h-4 w-4 animate-spin rounded-full border-2 border-primary-foreground border-t-transparent"></span>
|
||||
Signing in...
|
||||
</span>
|
||||
{:else}
|
||||
Sign In
|
||||
{/if}
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
{#if showLocalForm}
|
||||
<p class="mt-6 text-center text-sm text-muted-foreground">
|
||||
Don't have an account?
|
||||
<a href="/register" class="font-medium text-primary hover:underline">Register</a>
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
Reference in New Issue
Block a user