Initial commit

This commit is contained in:
Jarek Krochmalski
2025-12-28 21:16:03 +01:00
commit 62e3c6439e
552 changed files with 104858 additions and 0 deletions

View File

@@ -0,0 +1,511 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import * as Dialog from '$lib/components/ui/dialog';
import * as Tabs from '$lib/components/ui/tabs';
import * as Select from '$lib/components/ui/select';
import { Label } from '$lib/components/ui/label';
import { Input } from '$lib/components/ui/input';
import { Checkbox } from '$lib/components/ui/checkbox';
import { LogIn, Pencil, Plus, Check, RefreshCw, Crown, Key, Shield, Trash2, TriangleAlert } from 'lucide-svelte';
import * as Alert from '$lib/components/ui/alert';
import { focusFirstInput } from '$lib/utils';
export interface OidcConfig {
id: number;
name: string;
enabled: boolean;
issuerUrl: string;
clientId: string;
clientSecret: string;
redirectUri: string;
scopes: string;
usernameClaim: string;
emailClaim: string;
displayNameClaim: string;
adminClaim?: string;
adminValue?: string;
roleMappingsClaim?: string;
roleMappings?: { claimValue: string; roleId: number }[];
}
export interface Role {
id: number;
name: string;
description?: string;
is_system: boolean;
permissions: any;
created_at: string;
}
interface Props {
open: boolean;
oidc?: OidcConfig | null;
roles: Role[];
isEnterprise: boolean;
onClose: () => void;
onSaved: () => void;
onNavigateToLicense?: () => void;
}
let { open = $bindable(), oidc = null, roles, isEnterprise, onClose, onSaved, onNavigateToLicense }: Props = $props();
const isEditing = $derived(oidc !== null);
// Form state
let formName = $state('');
let formEnabled = $state(false);
let formIssuerUrl = $state('');
let formClientId = $state('');
let formClientSecret = $state('');
let formRedirectUri = $state('');
let formScopes = $state('openid profile email');
let formUsernameClaim = $state('preferred_username');
let formEmailClaim = $state('email');
let formDisplayNameClaim = $state('name');
let formAdminClaim = $state('');
let formAdminValue = $state('');
let formRoleMappingsClaim = $state('groups');
let formRoleMappings = $state<{ claim_value: string; role_id: number }[]>([]);
let formActiveTab = $state('general');
let formError = $state('');
let formErrors = $state<{ name?: string; issuerUrl?: string; clientId?: string; clientSecret?: string; redirectUri?: string }>({});
let formSaving = $state(false);
function resetForm() {
formName = '';
formEnabled = false;
formIssuerUrl = '';
formClientId = '';
formClientSecret = '';
formRedirectUri = typeof window !== 'undefined' ? `${window.location.origin}/api/auth/oidc/callback` : '';
formScopes = 'openid profile email';
formUsernameClaim = 'preferred_username';
formEmailClaim = 'email';
formDisplayNameClaim = 'name';
formAdminClaim = '';
formAdminValue = '';
formRoleMappingsClaim = 'groups';
formRoleMappings = [];
formActiveTab = 'general';
formError = '';
formErrors = {};
formSaving = false;
}
// Initialize form when oidc changes or modal opens
$effect(() => {
if (open) {
if (oidc) {
formName = oidc.name;
formEnabled = oidc.enabled;
formIssuerUrl = oidc.issuerUrl;
formClientId = oidc.clientId;
formClientSecret = oidc.clientSecret;
formRedirectUri = oidc.redirectUri;
formScopes = oidc.scopes || 'openid profile email';
formUsernameClaim = oidc.usernameClaim || 'preferred_username';
formEmailClaim = oidc.emailClaim || 'email';
formDisplayNameClaim = oidc.displayNameClaim || 'name';
formAdminClaim = oidc.adminClaim || '';
formAdminValue = oidc.adminValue || '';
formRoleMappingsClaim = oidc.roleMappingsClaim || 'groups';
formRoleMappings = oidc.roleMappings ? oidc.roleMappings.map(m => ({ claim_value: m.claimValue, role_id: m.roleId })) : [];
formActiveTab = 'general';
formError = '';
formErrors = {};
formSaving = false;
} else {
resetForm();
}
}
});
async function save() {
formErrors = {};
let hasErrors = false;
if (!formName.trim()) {
formErrors.name = 'Name is required';
hasErrors = true;
}
if (!formIssuerUrl.trim()) {
formErrors.issuerUrl = 'Issuer URL is required';
hasErrors = true;
}
if (!formClientId.trim()) {
formErrors.clientId = 'Client ID is required';
hasErrors = true;
}
if (!isEditing && !formClientSecret.trim()) {
formErrors.clientSecret = 'Client secret is required';
hasErrors = true;
}
if (!formRedirectUri.trim()) {
formErrors.redirectUri = 'Redirect URI is required';
hasErrors = true;
}
if (hasErrors) return;
formSaving = true;
formError = '';
try {
const url = isEditing ? `/api/auth/oidc/${oidc!.id}` : '/api/auth/oidc';
const method = isEditing ? 'PUT' : 'POST';
const response = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: formName.trim(),
enabled: formEnabled,
issuerUrl: formIssuerUrl.trim(),
clientId: formClientId.trim(),
clientSecret: formClientSecret.trim(),
redirectUri: formRedirectUri.trim(),
scopes: formScopes.trim() || 'openid profile email',
usernameClaim: formUsernameClaim.trim() || 'preferred_username',
emailClaim: formEmailClaim.trim() || 'email',
displayNameClaim: formDisplayNameClaim.trim() || 'name',
adminClaim: formAdminClaim.trim() || undefined,
adminValue: formAdminValue.trim() || undefined,
roleMappings: formRoleMappings.length > 0 ? formRoleMappings.map(m => ({ claimValue: m.claim_value, roleId: m.role_id })) : undefined
})
});
if (response.ok) {
open = false;
onSaved();
} else {
const data = await response.json();
formError = data.error || `Failed to ${isEditing ? 'update' : 'create'} OIDC configuration`;
}
} catch {
formError = `Failed to ${isEditing ? 'update' : 'create'} OIDC configuration`;
} finally {
formSaving = false;
}
}
function handleClose() {
open = false;
onClose();
}
function addRoleMapping() {
formRoleMappings = [...formRoleMappings, { claim_value: '', role_id: 0 }];
}
function removeRoleMapping(index: number) {
formRoleMappings = formRoleMappings.filter((_, i) => i !== index);
}
function updateRoleMappingRole(index: number, roleId: number) {
formRoleMappings[index].role_id = roleId;
formRoleMappings = [...formRoleMappings];
}
</script>
<Dialog.Root bind:open onOpenChange={(o) => { if (o) { formError = ''; formErrors = {}; focusFirstInput(); } }}>
<Dialog.Content class="max-w-2xl h-[80vh] flex flex-col overflow-hidden">
<Dialog.Header class="flex-shrink-0">
<Dialog.Title class="flex items-center gap-2">
{#if isEditing}
<Pencil class="w-5 h-5" />
Edit OIDC provider
{:else}
<LogIn class="w-5 h-5" />
Add OIDC provider
{/if}
</Dialog.Title>
</Dialog.Header>
<Tabs.Root bind:value={formActiveTab} class="flex-1 flex flex-col overflow-hidden">
<Tabs.List class="flex-shrink-0 grid w-full grid-cols-2">
<Tabs.Trigger value="general">General</Tabs.Trigger>
<Tabs.Trigger value="role-mapping" class="flex items-center gap-1.5">
<Crown class="w-3.5 h-3.5 text-amber-500" />
Role mapping
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="general" class="flex-1 overflow-y-auto space-y-4 py-2 mt-0">
{#if formError}
<Alert.Root variant="destructive">
<TriangleAlert class="h-4 w-4" />
<Alert.Description>{formError}</Alert.Description>
</Alert.Root>
{/if}
<!-- Basic Settings -->
<div class="space-y-4">
<h4 class="text-sm font-medium text-muted-foreground">Basic settings</h4>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label>Name <span class="text-destructive">*</span></Label>
<Input
bind:value={formName}
placeholder="Okta, Auth0, Azure AD..."
class={formErrors.name ? 'border-destructive focus-visible:ring-destructive' : ''}
oninput={() => formErrors.name = undefined}
/>
{#if formErrors.name}
<p class="text-xs text-destructive">{formErrors.name}</p>
{/if}
</div>
<div class="space-y-2">
<Label>Issuer URL <span class="text-destructive">*</span></Label>
<Input
bind:value={formIssuerUrl}
placeholder="https://example.okta.com"
class={formErrors.issuerUrl ? 'border-destructive focus-visible:ring-destructive' : ''}
oninput={() => formErrors.issuerUrl = undefined}
/>
{#if formErrors.issuerUrl}
<p class="text-xs text-destructive">{formErrors.issuerUrl}</p>
{/if}
</div>
</div>
<div class="flex items-center gap-2">
<Checkbox
checked={formEnabled}
onCheckedChange={(checked) => formEnabled = checked === true}
/>
<Label class="text-sm font-normal cursor-pointer" onclick={() => formEnabled = !formEnabled}>
Enable this OIDC provider
</Label>
</div>
</div>
<!-- Client Credentials -->
<div class="space-y-4">
<h4 class="text-sm font-medium text-muted-foreground">Client credentials</h4>
<p class="text-xs text-muted-foreground">Get these from your identity provider's application settings.</p>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label>Client ID <span class="text-destructive">*</span></Label>
<Input
bind:value={formClientId}
placeholder="your-client-id"
class={formErrors.clientId ? 'border-destructive focus-visible:ring-destructive' : ''}
oninput={() => formErrors.clientId = undefined}
/>
{#if formErrors.clientId}
<p class="text-xs text-destructive">{formErrors.clientId}</p>
{/if}
</div>
<div class="space-y-2">
<Label>Client secret {#if !isEditing}<span class="text-destructive">*</span>{/if}</Label>
<Input
type="password"
bind:value={formClientSecret}
placeholder={isEditing ? 'Leave blank to keep existing' : 'your-client-secret'}
class={formErrors.clientSecret ? 'border-destructive focus-visible:ring-destructive' : ''}
oninput={() => formErrors.clientSecret = undefined}
/>
{#if formErrors.clientSecret}
<p class="text-xs text-destructive">{formErrors.clientSecret}</p>
{/if}
</div>
</div>
</div>
<!-- Redirect & Scopes -->
<div class="space-y-4">
<h4 class="text-sm font-medium text-muted-foreground">Redirect settings</h4>
<div class="space-y-2">
<Label>Redirect URI <span class="text-destructive">*</span></Label>
<Input
bind:value={formRedirectUri}
placeholder="https://dockhand.example.com/api/auth/oidc/callback"
class={formErrors.redirectUri ? 'border-destructive focus-visible:ring-destructive' : ''}
oninput={() => formErrors.redirectUri = undefined}
/>
{#if formErrors.redirectUri}
<p class="text-xs text-destructive">{formErrors.redirectUri}</p>
{:else}
<p class="text-xs text-muted-foreground">Add this URI to your identity provider's allowed callback URLs.</p>
{/if}
</div>
<div class="space-y-2">
<Label>Scopes</Label>
<Input
bind:value={formScopes}
placeholder="openid profile email"
/>
</div>
</div>
<!-- Claim Mapping -->
<div class="space-y-4">
<h4 class="text-sm font-medium text-muted-foreground">Claim mapping</h4>
<p class="text-xs text-muted-foreground">Map OIDC claims to user attributes.</p>
<div class="grid grid-cols-3 gap-4">
<div class="space-y-2">
<Label>Username claim</Label>
<Input
bind:value={formUsernameClaim}
placeholder="preferred_username"
/>
</div>
<div class="space-y-2">
<Label>Email claim</Label>
<Input
bind:value={formEmailClaim}
placeholder="email"
/>
</div>
<div class="space-y-2">
<Label>Display name claim</Label>
<Input
bind:value={formDisplayNameClaim}
placeholder="name"
/>
</div>
</div>
</div>
</Tabs.Content>
<Tabs.Content value="role-mapping" class="flex-1 overflow-y-auto space-y-4 py-2 mt-0">
{#if !isEnterprise}
<!-- Enterprise Feature Notice (no license) -->
<div class="flex-1 flex items-center justify-center py-8">
<div class="text-center">
<h3 class="text-lg font-medium mb-2 flex items-center justify-center gap-2">
<Crown class="w-5 h-5 text-amber-500" />
Enterprise feature
</h3>
<p class="text-sm text-muted-foreground mb-4 max-w-md mx-auto">
Role mapping allows you to automatically assign Dockhand roles based on your identity provider's groups or claims. This feature requires an enterprise license.
</p>
{#if onNavigateToLicense}
<Button onclick={() => { open = false; onNavigateToLicense?.(); }}>
<Key class="w-4 h-4 mr-2" />
Activate license
</Button>
{/if}
</div>
</div>
{:else}
<!-- Admin Mapping (Simple) -->
<div class="space-y-4">
<h4 class="text-sm font-medium text-muted-foreground">Groups/roles claim</h4>
<p class="text-xs text-muted-foreground">Grant admin access based on claim values from your identity provider.</p>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label>Claim name</Label>
<Input
bind:value={formAdminClaim}
placeholder="groups, roles, etc."
/>
<p class="text-xs text-muted-foreground">Name of the claim containing roles/groups</p>
</div>
<div class="space-y-2">
<Label>Admin value(s)</Label>
<Input
bind:value={formAdminValue}
placeholder="admin, Administrators"
/>
<p class="text-xs text-muted-foreground">Comma-separated values that grant Admin role</p>
</div>
</div>
</div>
<!-- Role Mappings Grid -->
<div class="space-y-4">
<div class="flex items-center justify-between">
<div>
<h4 class="text-sm font-medium text-muted-foreground">Claim to role mappings</h4>
<p class="text-xs text-muted-foreground mt-0.5">Map claim values from your identity provider to Dockhand roles.</p>
</div>
<Button
size="sm"
variant="outline"
onclick={addRoleMapping}
>
<Plus class="w-4 h-4 mr-1" />
Add mapping
</Button>
</div>
{#if formRoleMappings.length === 0}
<div class="text-center py-6 text-muted-foreground text-sm border border-dashed rounded-lg">
No role mappings configured. Click "Add mapping" to create one.
</div>
{:else}
<div class="space-y-2">
{#each formRoleMappings as mapping, index}
<div class="flex items-center gap-3 p-3 bg-muted/50 rounded-lg">
<div class="flex-1 grid grid-cols-2 gap-3">
<div class="space-y-1">
<Label class="text-xs">Claim value</Label>
<Input
bind:value={mapping.claim_value}
placeholder="e.g., developers, admins"
class="h-8"
/>
</div>
<div class="space-y-1">
<Label class="text-xs">Dockhand role</Label>
<Select.Root
type="single"
value={mapping.role_id ? String(mapping.role_id) : undefined}
onValueChange={(value) => {
if (value) {
updateRoleMappingRole(index, parseInt(value));
}
}}
>
<Select.Trigger class="h-8">
{#if mapping.role_id}
{roles.find(r => r.id === mapping.role_id)?.name || 'Select role...'}
{:else}
Select role...
{/if}
</Select.Trigger>
<Select.Content>
{#each roles as role}
<Select.Item value={String(role.id)}>
<div class="flex items-center gap-2">
<Shield class="w-3.5 h-3.5 text-muted-foreground" />
{role.name}
</div>
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
</div>
<Button
size="sm"
variant="ghost"
class="text-destructive hover:text-destructive h-8 w-8 p-0"
onclick={() => removeRoleMapping(index)}
>
<Trash2 class="w-4 h-4" />
</Button>
</div>
{/each}
</div>
{/if}
</div>
{/if}
</Tabs.Content>
</Tabs.Root>
<Dialog.Footer class="flex-shrink-0 border-t pt-4">
<Button variant="outline" onclick={handleClose}>Cancel</Button>
<Button onclick={save} disabled={formSaving}>
{#if formSaving}
<RefreshCw class="w-4 h-4 mr-1 animate-spin" />
{:else if isEditing}
<Check class="w-4 h-4 mr-1" />
{:else}
<Plus class="w-4 h-4 mr-1" />
{/if}
{isEditing ? 'Save' : 'Add provider'}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View File

@@ -0,0 +1,289 @@
<script lang="ts">
import { onMount } from 'svelte';
import { toast } from 'svelte-sonner';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import { Badge } from '$lib/components/ui/badge';
import {
LogIn,
Plus,
Pencil,
Trash2,
RefreshCw,
Check,
Pause,
Play,
Zap,
XCircle
} from 'lucide-svelte';
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
import { canAccess } from '$lib/stores/auth';
import { licenseStore } from '$lib/stores/license';
import OidcModal from './OidcModal.svelte';
import { EmptyState } from '$lib/components/ui/empty-state';
interface OidcConfig {
id: number;
name: string;
enabled: boolean;
issuerUrl: string;
clientId: string;
clientSecret: string;
redirectUri: string;
scopes: string;
usernameClaim: string;
emailClaim: string;
displayNameClaim: string;
adminClaim?: string;
adminValue?: string;
roleMappingsClaim?: string;
roleMappings?: { claimValue: string; roleId: number }[];
}
interface Role {
id: number;
name: string;
description?: string;
is_system: boolean;
permissions: any;
created_at: string;
}
interface Props {
roles: Role[];
}
let { roles }: Props = $props();
// OIDC/SSO state
let oidcConfigs = $state<OidcConfig[]>([]);
let oidcLoading = $state(true);
let showOidcModal = $state(false);
let editingOidc = $state<OidcConfig | null>(null);
let confirmDeleteOidcId = $state<number | null>(null);
let oidcTesting = $state<number | null>(null);
let oidcTestResult = $state<{ success: boolean; error?: string; issuer?: string; endpoints?: any } | null>(null);
async function fetchOidcConfigs() {
oidcLoading = true;
try {
const response = await fetch('/api/auth/oidc');
if (response.ok) {
oidcConfigs = await response.json();
}
} catch (error) {
console.error('Failed to fetch OIDC configs:', error);
toast.error('Failed to fetch OIDC configurations');
} finally {
oidcLoading = false;
}
}
function openOidcModal(config: OidcConfig | null) {
editingOidc = config;
showOidcModal = true;
}
function handleOidcModalClose() {
showOidcModal = false;
editingOidc = null;
}
async function handleOidcModalSaved() {
showOidcModal = false;
editingOidc = null;
await fetchOidcConfigs();
}
async function deleteOidcConfig(configId: number) {
try {
const response = await fetch(`/api/auth/oidc/${configId}`, { method: 'DELETE' });
if (response.ok) {
await fetchOidcConfigs();
toast.success('OIDC provider deleted');
} else {
toast.error('Failed to delete OIDC provider');
}
} catch (error) {
console.error('Failed to delete OIDC config:', error);
toast.error('Failed to delete OIDC provider');
} finally {
confirmDeleteOidcId = null;
}
}
async function testOidcConnection(configId: number) {
oidcTesting = configId;
oidcTestResult = null;
try {
const response = await fetch(`/api/auth/oidc/${configId}/test`, { method: 'POST' });
const data = await response.json();
oidcTestResult = data;
if (data.success) {
toast.success('OIDC connection successful');
} else {
toast.error(`OIDC connection failed: ${data.error}`);
}
} catch (error) {
oidcTestResult = { success: false, error: 'Failed to test connection' };
toast.error('Failed to test OIDC connection');
} finally {
oidcTesting = null;
}
}
async function toggleOidcEnabled(config: OidcConfig) {
try {
const response = await fetch(`/api/auth/oidc/${config.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...config, enabled: !config.enabled })
});
if (response.ok) {
await fetchOidcConfigs();
toast.success(`OIDC provider ${config.enabled ? 'disabled' : 'enabled'}`);
} else {
toast.error('Failed to toggle OIDC provider');
}
} catch (error) {
console.error('Failed to toggle OIDC config:', error);
toast.error('Failed to toggle OIDC provider');
}
}
onMount(() => {
fetchOidcConfigs();
});
</script>
<div class="space-y-4">
<Card.Root>
<Card.Header>
<div class="flex items-center justify-between">
<div>
<Card.Title class="text-sm font-medium flex items-center gap-2">
<LogIn class="w-4 h-4" />
SSO providers
</Card.Title>
<p class="text-xs text-muted-foreground mt-1">Enable SSO using OpenID Connect providers like Okta, Auth0, Azure AD, or Google Workspace.</p>
</div>
{#if $canAccess('settings', 'edit')}
<Button size="sm" onclick={() => openOidcModal(null)}>
<Plus class="w-4 h-4 mr-1" />
Add provider
</Button>
{/if}
</div>
</Card.Header>
<Card.Content>
{#if oidcLoading}
<div class="flex items-center justify-center py-4">
<RefreshCw class="w-6 h-6 animate-spin text-muted-foreground" />
</div>
{:else if oidcConfigs.length === 0}
<EmptyState
icon={LogIn}
title="No SSO providers configured"
description="Add an OIDC provider to enable single sign-on"
class="py-8"
/>
{:else}
<div class="space-y-2">
{#each oidcConfigs as config}
<div class="flex items-center justify-between p-3 border rounded-md">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="font-medium text-sm">{config.name}</span>
{#if config.enabled}
<Badge variant="default" class="text-xs">Enabled</Badge>
{:else}
<Badge variant="outline" class="text-xs">Disabled</Badge>
{/if}
</div>
<span class="text-xs text-muted-foreground truncate block">{config.issuerUrl}</span>
</div>
<div class="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
title="Test connection"
onclick={() => testOidcConnection(config.id)}
disabled={oidcTesting === config.id}
>
{#if oidcTesting === config.id}
<RefreshCw class="w-4 h-4 animate-spin" />
{:else}
<Zap class="w-4 h-4" />
{/if}
</Button>
{#if $canAccess('settings', 'edit')}
<Button
variant="ghost"
size="sm"
title={config.enabled ? 'Disable provider' : 'Enable provider'}
onclick={() => toggleOidcEnabled(config)}
>
{#if config.enabled}
<Pause class="w-4 h-4" />
{:else}
<Play class="w-4 h-4" />
{/if}
</Button>
<Button
variant="ghost"
size="sm"
title="Edit provider"
onclick={() => openOidcModal(config)}
>
<Pencil class="w-4 h-4" />
</Button>
<ConfirmPopover
open={confirmDeleteOidcId === config.id}
action="Delete"
itemType="OIDC provider"
itemName={config.name}
title="Delete"
onConfirm={() => deleteOidcConfig(config.id)}
onOpenChange={(open) => confirmDeleteOidcId = open ? config.id : null}
>
{#snippet children({ open })}
<Trash2 class="w-4 h-4 {open ? 'text-destructive' : 'text-muted-foreground hover:text-destructive'}" />
{/snippet}
</ConfirmPopover>
{/if}
</div>
</div>
{/each}
</div>
{/if}
{#if oidcTestResult}
<div class="mt-4 p-3 border rounded-md {oidcTestResult.success ? 'border-green-500 bg-green-500/10' : 'border-destructive bg-destructive/10'}">
{#if oidcTestResult.success}
<div class="flex items-center gap-2 text-green-600">
<Check class="w-4 h-4" />
<p class="text-sm font-medium">Connection successful</p>
</div>
{#if oidcTestResult.issuer}
<p class="text-xs text-muted-foreground mt-1">Issuer: {oidcTestResult.issuer}</p>
{/if}
{:else}
<div class="flex items-center gap-2 text-destructive">
<XCircle class="w-4 h-4" />
<p class="text-sm">Connection failed: {oidcTestResult.error}</p>
</div>
{/if}
</div>
{/if}
</Card.Content>
</Card.Root>
</div>
<OidcModal
bind:open={showOidcModal}
oidc={editingOidc}
{roles}
isEnterprise={$licenseStore.isEnterprise}
onClose={handleOidcModalClose}
onSaved={handleOidcModalSaved}
/>