mirror of
https://github.com/Dvorinka/Primora.git
synced 2026-10-29 16:14:53 +00:00
feat: ship phase 1 surface area (v0.2.0)
- primora CLI + @primora/mcp server over generated client - auth users admin panel, mobile bottom nav - release workflow: GHCR images, compiled binaries, npm tarballs - fix gzip Content-Length truncation, nginx upstream re-resolution Authored By: TDvorak <[email protected]> Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
f77823ad90
commit
420502eec1
90 files changed
+9172
-9718
No files matched your search
@@ -1,298 +1,241 @@
|
||||
import { Show, For } from "solid-js";
|
||||
import { Button, Card, Input, Select, Badge, Table, EmptyState } from "../components";
|
||||
import type { AuditLog } from "@primora/api-client";
|
||||
import { For, Show, createSignal } from "solid-js";
|
||||
import type { AuditLog, AuditLogListResponse } from "@primora/api-client";
|
||||
import { Badge } from "../components/Badge";
|
||||
import { Input, Select } from "../components/Input";
|
||||
import { IconAudit, IconRefresh, IconDownload } from "../components/Icons";
|
||||
|
||||
interface AuditPageProps {
|
||||
auditLogs?: AuditLog[];
|
||||
auditLogs: AuditLog[];
|
||||
auditSearch: string;
|
||||
auditAction: string;
|
||||
auditOffset: number;
|
||||
auditPage?: { items: AuditLog[]; total: number; limit: number; offset: number };
|
||||
auditPage?: AuditLogListResponse;
|
||||
onAuditSearchChange: (value: string) => void;
|
||||
onAuditActionChange: (value: string) => void;
|
||||
onAuditPageChange: (offset: number) => void;
|
||||
onRefreshAudit: () => void;
|
||||
formatDate: (date?: string | null) => string;
|
||||
formatDate: (value?: string | null) => string;
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
const ACTION_OPTIONS = [
|
||||
"",
|
||||
"project.created",
|
||||
"project.updated",
|
||||
"project.deleted",
|
||||
"bucket.created",
|
||||
"bucket.updated",
|
||||
"bucket.deleted",
|
||||
"object.uploaded",
|
||||
"object.updated",
|
||||
"object.deleted",
|
||||
"api_key.created",
|
||||
"api_key.revoked",
|
||||
"member.invited",
|
||||
"member.role_updated",
|
||||
"member.removed",
|
||||
"collection.created",
|
||||
"collection.deleted",
|
||||
"document.created",
|
||||
"document.updated",
|
||||
"document.deleted",
|
||||
];
|
||||
|
||||
function actionVariant(action: string): "success" | "error" | "primary" | "warning" | "neutral" {
|
||||
if (action.includes("delete") || action.includes("revoke") || action.includes("remove")) {
|
||||
return "error";
|
||||
}
|
||||
if (action.includes("create") || action.includes("upload") || action.includes("invite")) {
|
||||
return "success";
|
||||
}
|
||||
if (action.includes("update") || action.includes("role")) {
|
||||
return "primary";
|
||||
}
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
function exportLogs(logs: AuditLog[], format: "json" | "csv") {
|
||||
let content: string;
|
||||
let mime: string;
|
||||
let ext: string;
|
||||
|
||||
if (format === "csv") {
|
||||
const header = "id,created_at,action,resource_type,resource_id,request_id";
|
||||
const rows = logs.map((l) =>
|
||||
[l.id, l.created_at, l.action, l.resource_type, l.resource_id, l.request_id]
|
||||
.map((v) => `"${String(v ?? "").replace(/"/g, '""')}"`)
|
||||
.join(","),
|
||||
);
|
||||
content = [header, ...rows].join("\n");
|
||||
mime = "text/csv";
|
||||
ext = "csv";
|
||||
} else {
|
||||
content = JSON.stringify(logs, null, 2);
|
||||
mime = "application/json";
|
||||
ext = "json";
|
||||
}
|
||||
|
||||
const blob = new Blob([content], { type: mime });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `audit-log-${new Date().toISOString().slice(0, 10)}.${ext}`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export function AuditPage(props: AuditPageProps) {
|
||||
const getActionBadgeVariant = (action: string) => {
|
||||
if (action.includes('create')) return 'success';
|
||||
if (action.includes('delete')) return 'danger';
|
||||
if (action.includes('update')) return 'primary';
|
||||
return 'secondary';
|
||||
};
|
||||
const [expandedId, setExpandedId] = createSignal<string | null>(null);
|
||||
|
||||
const getActionIcon = (action: string) => {
|
||||
if (action.includes('create')) {
|
||||
return (
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
if (action.includes('delete')) {
|
||||
return (
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
if (action.includes('update')) {
|
||||
return (
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
const formatResourceType = (type: string) => {
|
||||
return type.split('_').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ');
|
||||
};
|
||||
const total = () => props.auditPage?.total ?? props.auditLogs.length;
|
||||
const currentPage = () => Math.floor(props.auditOffset / PAGE_SIZE) + 1;
|
||||
const totalPages = () => Math.max(1, Math.ceil(total() / PAGE_SIZE));
|
||||
|
||||
return (
|
||||
<div class="space-y-6">
|
||||
{/* Header */}
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900">Audit Logs</h1>
|
||||
<p class="text-gray-600 mt-1">Track all activities and changes in your project</p>
|
||||
<h1 class="page-title">Audit log</h1>
|
||||
<p class="page-description">
|
||||
Every mutating request against this project, recorded with request IDs.
|
||||
</p>
|
||||
</div>
|
||||
<div class="page-actions">
|
||||
<button class="btn btn-ghost" onClick={props.onRefreshAudit}>
|
||||
<IconRefresh class="w-4 h-4" />
|
||||
Refresh
|
||||
</button>
|
||||
<button class="btn btn-secondary" onClick={() => exportLogs(props.auditLogs, "csv")}>
|
||||
<IconDownload class="w-4 h-4" />
|
||||
CSV
|
||||
</button>
|
||||
<button class="btn btn-secondary" onClick={() => exportLogs(props.auditLogs, "json")}>
|
||||
<IconDownload class="w-4 h-4" />
|
||||
JSON
|
||||
</button>
|
||||
</div>
|
||||
<Button variant="secondary" onClick={props.onRefreshAudit}>
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<Card class="p-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<div style="flex:1;min-width:14rem;max-width:24rem">
|
||||
<Input
|
||||
placeholder="Search by resource, actor, or details..."
|
||||
placeholder="Search by resource, action or request ID…"
|
||||
class="input-sm"
|
||||
value={props.auditSearch}
|
||||
onInput={(e) => props.onAuditSearchChange(e.currentTarget.value)}
|
||||
/>
|
||||
<Select
|
||||
value={props.auditAction}
|
||||
onChange={(e) => props.onAuditActionChange(e.currentTarget.value)}
|
||||
>
|
||||
<option value="">All Actions</option>
|
||||
<option value="create">Create</option>
|
||||
<option value="update">Update</option>
|
||||
<option value="delete">Delete</option>
|
||||
<option value="upload">Upload</option>
|
||||
<option value="download">Download</option>
|
||||
</Select>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<Card class="p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-2xl font-bold text-gray-900">{props.auditPage?.total ?? 0}</p>
|
||||
<p class="text-sm text-gray-600">Total Events</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card class="p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 bg-green-100 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-2xl font-bold text-gray-900">
|
||||
{props.auditLogs?.filter(log => log.action.includes('create')).length ?? 0}
|
||||
</p>
|
||||
<p class="text-sm text-gray-600">Creates</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card class="p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 bg-yellow-100 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-yellow-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-2xl font-bold text-gray-900">
|
||||
{props.auditLogs?.filter(log => log.action.includes('update')).length ?? 0}
|
||||
</p>
|
||||
<p class="text-sm text-gray-600">Updates</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card class="p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 bg-red-100 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-2xl font-bold text-gray-900">
|
||||
{props.auditLogs?.filter(log => log.action.includes('delete')).length ?? 0}
|
||||
</p>
|
||||
<p class="text-sm text-gray-600">Deletes</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Select
|
||||
class="input-sm"
|
||||
style="width:14rem"
|
||||
value={props.auditAction}
|
||||
onChange={(e) => props.onAuditActionChange(e.currentTarget.value)}
|
||||
aria-label="Filter by action"
|
||||
>
|
||||
<option value="">All actions</option>
|
||||
<For each={ACTION_OPTIONS.slice(1)}>
|
||||
{(action) => <option value={action}>{action}</option>}
|
||||
</For>
|
||||
</Select>
|
||||
<span class="text-xs text-text-3 ml-auto">
|
||||
{total()} event{total() === 1 ? "" : "s"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Audit Logs Table */}
|
||||
<Card>
|
||||
<div class="card card-flush">
|
||||
<Show
|
||||
when={(props.auditLogs?.length ?? 0) > 0}
|
||||
when={props.auditLogs.length > 0}
|
||||
fallback={
|
||||
<div class="p-12">
|
||||
<EmptyState
|
||||
icon={
|
||||
<svg class="w-16 h-16" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01" />
|
||||
</svg>
|
||||
}
|
||||
title="No audit logs"
|
||||
description={props.auditSearch || props.auditAction ? "No logs match your filters" : "Activity will appear here as you use the platform"}
|
||||
/>
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-icon">
|
||||
<IconAudit class="w-5 h-5" />
|
||||
</div>
|
||||
<p class="empty-state-title">No audit events</p>
|
||||
<p class="empty-state-description">
|
||||
Actions on this project — uploads, key creation, member changes — will appear here.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="overflow-x-auto">
|
||||
<Table>
|
||||
<div class="table-container">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Timestamp</th>
|
||||
<th>Action</th>
|
||||
<th>Event</th>
|
||||
<th>Resource</th>
|
||||
<th>Actor</th>
|
||||
<th>Details</th>
|
||||
<th>Request</th>
|
||||
<th style="width:1%">Time</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={props.auditLogs}>
|
||||
{(log) => (
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="text-sm text-gray-600 whitespace-nowrap">
|
||||
{props.formatDate(log.created_at)}
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex items-center gap-2">
|
||||
<Badge variant={getActionBadgeVariant(log.action)} class="flex items-center gap-1">
|
||||
{getActionIcon(log.action)}
|
||||
{log.action}
|
||||
</Badge>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div>
|
||||
<p class="font-medium text-gray-900">{formatResourceType(log.resource_type)}</p>
|
||||
<Show when={log.resource_id}>
|
||||
<code class="text-xs text-gray-500">{log.resource_id}</code>
|
||||
</Show>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-8 h-8 rounded-full bg-gradient-to-br from-purple-500 to-pink-500 flex items-center justify-center text-white text-xs font-semibold">
|
||||
{log.actor_name?.charAt(0).toUpperCase() ?? '?'}
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900">{log.actor_name}</p>
|
||||
<p class="text-xs text-gray-500">{log.actor_email}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<Show when={log.details}>
|
||||
<details class="text-sm">
|
||||
<summary class="cursor-pointer text-blue-600 hover:text-blue-700">
|
||||
View details
|
||||
</summary>
|
||||
<pre class="mt-2 p-2 bg-gray-50 rounded text-xs overflow-x-auto">
|
||||
{JSON.stringify(log.details, null, 2)}
|
||||
<>
|
||||
<tr
|
||||
class="clickable"
|
||||
onClick={() =>
|
||||
setExpandedId(expandedId() === log.id ? null : log.id)
|
||||
}
|
||||
>
|
||||
<td>
|
||||
<Badge variant={actionVariant(log.action)}>{log.action}</Badge>
|
||||
</td>
|
||||
<td>
|
||||
<span class="text-text-2">{log.resource_type}</span>{" "}
|
||||
<code class="text-xs">{log.resource_id}</code>
|
||||
</td>
|
||||
<td>
|
||||
<code class="text-xs text-text-3">{log.request_id}</code>
|
||||
</td>
|
||||
<td class="text-xs text-text-3 whitespace-nowrap">
|
||||
{props.formatDate(log.created_at)}
|
||||
</td>
|
||||
</tr>
|
||||
<Show when={expandedId() === log.id && Object.keys(log.metadata ?? {}).length > 0}>
|
||||
<tr>
|
||||
<td colspan={4} style="background:var(--bg-panel);padding:0.75rem 1.25rem">
|
||||
<pre class="code-block" style="border:0;padding:0;background:transparent">
|
||||
{JSON.stringify(log.metadata, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
</Show>
|
||||
</td>
|
||||
</tr>
|
||||
</td>
|
||||
</tr>
|
||||
</Show>
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</Table>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<Show when={props.auditPage && props.auditPage.total > props.auditPage.limit}>
|
||||
<div class="p-4 border-t border-gray-200 flex items-center justify-between">
|
||||
<p class="text-sm text-gray-600">
|
||||
Showing {props.auditPage!.offset + 1} to {Math.min(props.auditPage!.offset + props.auditPage!.limit, props.auditPage!.total)} of {props.auditPage!.total}
|
||||
</p>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={props.auditPage!.offset === 0}
|
||||
onClick={() => props.onAuditPageChange(Math.max(0, props.auditPage!.offset - props.auditPage!.limit))}
|
||||
<Show when={totalPages() > 1}>
|
||||
<div
|
||||
class="flex items-center justify-between px-4 py-3"
|
||||
style="border-top:1px solid var(--border)"
|
||||
>
|
||||
<span class="text-xs text-text-3">
|
||||
Page {currentPage()} of {totalPages()}
|
||||
</span>
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
class="btn btn-ghost btn-sm"
|
||||
disabled={currentPage() <= 1}
|
||||
onClick={() => props.onAuditPageChange((currentPage() - 2) * PAGE_SIZE)}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={props.auditPage!.offset + props.auditPage!.limit >= props.auditPage!.total}
|
||||
onClick={() => props.onAuditPageChange(props.auditPage!.offset + props.auditPage!.limit)}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-ghost btn-sm"
|
||||
disabled={!props.auditPage?.has_more}
|
||||
onClick={() => props.onAuditPageChange(currentPage() * PAGE_SIZE)}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</Card>
|
||||
|
||||
{/* Export Section */}
|
||||
<Card class="p-6 bg-gradient-to-r from-purple-50 to-blue-50 border-purple-200">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold mb-1">Export Audit Logs</h3>
|
||||
<p class="text-sm text-gray-600">Download audit logs for compliance and analysis</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button variant="outline">
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
Export CSV
|
||||
</Button>
|
||||
<Button variant="outline">
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
Export JSON
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import { For, Show, createMemo, createSignal } from "solid-js";
|
||||
import { Badge } from "../components/Badge";
|
||||
import { Input } from "../components/Input";
|
||||
import {
|
||||
IconAuth,
|
||||
IconRefresh,
|
||||
IconTrash,
|
||||
} from "../components/Icons";
|
||||
|
||||
/** Mirrors the better-auth admin `listUsers` user shape. */
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
emailVerified: boolean;
|
||||
role?: string | null;
|
||||
banned?: boolean | null;
|
||||
createdAt: string | Date;
|
||||
}
|
||||
|
||||
interface AuthPageProps {
|
||||
users?: AuthUser[];
|
||||
pending: boolean;
|
||||
message: string;
|
||||
error?: string;
|
||||
currentUserId?: string;
|
||||
onRefresh: () => void;
|
||||
onSetRole: (id: string, role: string) => void;
|
||||
onSetBanned: (id: string, banned: boolean) => void;
|
||||
onRemove: (id: string) => void;
|
||||
}
|
||||
|
||||
const authRoles = ["admin", "user"] as const;
|
||||
|
||||
function initials(name?: string, email?: string): string {
|
||||
const src = name || email || "?";
|
||||
return src
|
||||
.split(/[\s@.]+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((s) => s[0]?.toUpperCase())
|
||||
.join("");
|
||||
}
|
||||
|
||||
function formatDate(value?: string | Date | null): string {
|
||||
if (!value) return "—";
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "—";
|
||||
return date.toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
export function AuthPage(props: AuthPageProps) {
|
||||
const [query, setQuery] = createSignal("");
|
||||
|
||||
const filtered = createMemo(() => {
|
||||
const q = query().toLowerCase();
|
||||
const list = props.users ?? [];
|
||||
if (!q) return list;
|
||||
return list.filter(
|
||||
(u) => u.name.toLowerCase().includes(q) || u.email.toLowerCase().includes(q),
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">Authentication</h1>
|
||||
<p class="page-description">
|
||||
Users who can sign in to this Primora instance.
|
||||
</p>
|
||||
</div>
|
||||
<div class="page-actions">
|
||||
<button
|
||||
class="btn btn-ghost"
|
||||
onClick={props.onRefresh}
|
||||
disabled={props.pending}
|
||||
aria-label="Refresh users"
|
||||
>
|
||||
<IconRefresh class="w-4 h-4" />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={props.message}>
|
||||
<div class="message message-neutral">{props.message}</div>
|
||||
</Show>
|
||||
|
||||
<Show
|
||||
when={!props.error}
|
||||
fallback={
|
||||
<div class="card empty-state" style="min-height:16rem">
|
||||
<div class="empty-state-icon">
|
||||
<IconAuth class="w-5 h-5" />
|
||||
</div>
|
||||
<p class="empty-state-title">Admin access required</p>
|
||||
<p class="empty-state-description">{props.error}</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3 flex-wrap">
|
||||
<div class="tabs">
|
||||
<button class="tab active">
|
||||
Users
|
||||
<span class="nav-badge ml-1.5">{(props.users ?? []).length}</span>
|
||||
</button>
|
||||
</div>
|
||||
<Input
|
||||
placeholder="Filter users…"
|
||||
value={query()}
|
||||
onInput={(e) => setQuery(e.currentTarget.value)}
|
||||
class="input-sm"
|
||||
style="width:14rem"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="card card-flush">
|
||||
<Show
|
||||
when={filtered().length > 0}
|
||||
fallback={
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-icon">
|
||||
<IconAuth class="w-5 h-5" />
|
||||
</div>
|
||||
<p class="empty-state-title">No users found</p>
|
||||
<p class="empty-state-description">
|
||||
{query() ? "No users match this filter." : "No one has signed up yet."}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="table-container">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>User</th>
|
||||
<th>Status</th>
|
||||
<th>Role</th>
|
||||
<th>Joined</th>
|
||||
<th style="width:1%" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={filtered()}>
|
||||
{(user) => (
|
||||
<tr>
|
||||
<td>
|
||||
<div class="flex items-center gap-2.5">
|
||||
<span
|
||||
class="avatar"
|
||||
style="width:1.75rem;height:1.75rem;font-size:0.625rem"
|
||||
>
|
||||
{initials(user.name, user.email)}
|
||||
</span>
|
||||
<div>
|
||||
<div class="font-medium text-text-1">
|
||||
{user.name || "Unnamed"}
|
||||
<Show when={user.id === props.currentUserId}>
|
||||
<span class="text-xs text-text-3"> (you)</span>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="text-xs text-text-3">{user.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Badge variant={user.emailVerified ? "success" : "warning"}>
|
||||
<span class="badge-dot" />
|
||||
{user.emailVerified ? "Verified" : "Unverified"}
|
||||
</Badge>
|
||||
<Show when={user.banned}>
|
||||
<Badge variant="error">Banned</Badge>
|
||||
</Show>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<select
|
||||
class="select input-sm"
|
||||
style="width:8.5rem"
|
||||
value={user.role ?? "user"}
|
||||
onChange={(e) => props.onSetRole(user.id, e.currentTarget.value)}
|
||||
disabled={props.pending || user.id === props.currentUserId}
|
||||
aria-label={`Role for ${user.email}`}
|
||||
>
|
||||
<For each={authRoles}>
|
||||
{(role) => <option value={role}>{role}</option>}
|
||||
</For>
|
||||
</select>
|
||||
</td>
|
||||
<td class="text-text-3">{formatDate(user.createdAt)}</td>
|
||||
<td style="width:1%">
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
class="btn btn-ghost btn-sm"
|
||||
onClick={() => props.onSetBanned(user.id, !user.banned)}
|
||||
disabled={props.pending || user.id === props.currentUserId}
|
||||
>
|
||||
{user.banned ? "Unban" : "Ban"}
|
||||
</button>
|
||||
<button
|
||||
class="icon-btn"
|
||||
title="Delete user"
|
||||
aria-label={`Delete ${user.email}`}
|
||||
onClick={() => props.onRemove(user.id)}
|
||||
disabled={props.pending || user.id === props.currentUserId}
|
||||
>
|
||||
<IconTrash class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,284 +1,350 @@
|
||||
import { Show, For, createSignal, createMemo } from "solid-js";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Input,
|
||||
Textarea,
|
||||
Badge,
|
||||
EmptyState,
|
||||
Message,
|
||||
Modal,
|
||||
Table,
|
||||
DataTable
|
||||
} from "../components";
|
||||
import { For, Show, createMemo, createSignal } from "solid-js";
|
||||
import type { Collection, Document } from "@primora/api-client";
|
||||
import { Badge } from "../components/Badge";
|
||||
import { Modal, ModalFooter } from "../components/Modal";
|
||||
import { Input, Textarea } from "../components/Input";
|
||||
import {
|
||||
IconPlus,
|
||||
IconCollections,
|
||||
IconTrash,
|
||||
IconChevronRight,
|
||||
IconFile,
|
||||
} from "../components/Icons";
|
||||
|
||||
interface CollectionInput {
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface CollectionsPageProps {
|
||||
collections: Collection[];
|
||||
documents: Document[];
|
||||
selectedCollectionID?: string;
|
||||
collectionInput: { name: string; slug: string; description: string };
|
||||
collectionInput: CollectionInput;
|
||||
collectionMessage: string;
|
||||
collectionPending: boolean;
|
||||
documentPending: boolean;
|
||||
canUpdate: boolean;
|
||||
onCollectionInputChange: (field: string, value: string) => void;
|
||||
onCreateCollection: (e: SubmitEvent) => void;
|
||||
onCollectionInputChange: (field: keyof CollectionInput, value: string) => void;
|
||||
onCreateCollection: (event: SubmitEvent) => void;
|
||||
onDeleteCollection: (id: string) => void;
|
||||
onSelectCollection: (id: string) => void;
|
||||
onCreateDocument: (data: any) => void;
|
||||
onUpdateDocument: (id: string, data: any) => void;
|
||||
onCreateDocument: (data: Record<string, unknown>) => void;
|
||||
onUpdateDocument: (id: string, data: Record<string, unknown>) => void;
|
||||
onDeleteDocument: (id: string) => void;
|
||||
formatDate: (date?: string | null) => string;
|
||||
formatDate: (value?: string | null) => string;
|
||||
}
|
||||
|
||||
function prettyJson(value: Record<string, unknown>): string {
|
||||
return JSON.stringify(value, null, 2);
|
||||
}
|
||||
|
||||
export function CollectionsPage(props: CollectionsPageProps) {
|
||||
const [showCreateCollectionModal, setShowCreateCollectionModal] = createSignal(false);
|
||||
const [showDocumentModal, setShowDocumentModal] = createSignal(false);
|
||||
const [editingDocument, setEditingDocument] = createSignal<Document | null>(null);
|
||||
const [documentData, setDocumentData] = createSignal("");
|
||||
const [createOpen, setCreateOpen] = createSignal(false);
|
||||
const [docEditorOpen, setDocEditorOpen] = createSignal(false);
|
||||
const [editingDoc, setEditingDoc] = createSignal<Document | null>(null);
|
||||
const [docJson, setDocJson] = createSignal("{}");
|
||||
const [docError, setDocError] = createSignal("");
|
||||
|
||||
const activeCollection = createMemo(() =>
|
||||
props.collections.find(c => c.id === props.selectedCollectionID)
|
||||
const activeCollection = createMemo(() =>
|
||||
props.collections.find((c) => c.id === props.selectedCollectionID),
|
||||
);
|
||||
|
||||
const handleCreateCollection = (e: SubmitEvent) => {
|
||||
props.onCreateCollection(e);
|
||||
setShowCreateCollectionModal(false);
|
||||
const openNewDoc = () => {
|
||||
setEditingDoc(null);
|
||||
setDocJson("{\n \n}");
|
||||
setDocError("");
|
||||
setDocEditorOpen(true);
|
||||
};
|
||||
|
||||
const handleOpenDocumentModal = (doc: Document | null = null) => {
|
||||
setEditingDocument(doc);
|
||||
setDocumentData(doc ? JSON.stringify(doc.data, null, 2) : "{\n \n}");
|
||||
setShowDocumentModal(true);
|
||||
const openEditDoc = (doc: Document) => {
|
||||
setEditingDoc(doc);
|
||||
setDocJson(prettyJson(doc.data));
|
||||
setDocError("");
|
||||
setDocEditorOpen(true);
|
||||
};
|
||||
|
||||
const handleSaveDocument = (e: SubmitEvent) => {
|
||||
const submitDoc = (e: SubmitEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const data = JSON.parse(documentData());
|
||||
if (editingDocument()) {
|
||||
props.onUpdateDocument(editingDocument()!.id, data);
|
||||
} else {
|
||||
props.onCreateDocument(data);
|
||||
const parsed = JSON.parse(docJson());
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
||||
setDocError("Document data must be a JSON object.");
|
||||
return;
|
||||
}
|
||||
setShowDocumentModal(false);
|
||||
} catch (err) {
|
||||
alert("Invalid JSON data");
|
||||
const doc = editingDoc();
|
||||
if (doc) {
|
||||
props.onUpdateDocument(doc.id, parsed);
|
||||
} else {
|
||||
props.onCreateDocument(parsed);
|
||||
}
|
||||
setDocEditorOpen(false);
|
||||
} catch {
|
||||
setDocError("Invalid JSON. Check syntax and try again.");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="flex flex-col md:flex-row gap-6 h-[calc(100vh-12rem)]">
|
||||
{/* Sidebar: Collections List */}
|
||||
<div class="w-full md:w-64 flex flex-col gap-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-xl font-bold">Collections</h2>
|
||||
<Button size="sm" onClick={() => setShowCreateCollectionModal(true)}>
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
</Button>
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">Collections</h1>
|
||||
<p class="page-description">
|
||||
Document collections stored in Postgres — schema-flexible records for your app.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card class="flex-1 overflow-y-auto p-2">
|
||||
<Show
|
||||
when={props.collections.length > 0}
|
||||
fallback={<p class="text-sm text-gray-500 p-4 text-center">No collections</p>}
|
||||
<div class="page-actions">
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
onClick={() => setCreateOpen(true)}
|
||||
disabled={!props.canUpdate}
|
||||
>
|
||||
<div class="space-y-1">
|
||||
<For each={props.collections}>
|
||||
{(collection) => (
|
||||
<button
|
||||
onClick={() => props.onSelectCollection(collection.id)}
|
||||
class={`w-full text-left px-3 py-2 rounded-md text-sm transition-colors ${
|
||||
props.selectedCollectionID === collection.id
|
||||
? 'bg-blue-50 text-blue-700 font-medium'
|
||||
: 'text-gray-600 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<span>{collection.name}</span>
|
||||
<Show when={props.selectedCollectionID === collection.id}>
|
||||
<div class="w-1.5 h-1.5 rounded-full bg-blue-600" />
|
||||
</Show>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</Card>
|
||||
<IconPlus class="w-4 h-4" />
|
||||
New collection
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content: Documents Table */}
|
||||
<div class="flex-1 flex flex-col gap-4 min-w-0">
|
||||
<Show
|
||||
when={activeCollection()}
|
||||
fallback={
|
||||
<Card class="flex-1 flex items-center justify-center p-8">
|
||||
<EmptyState
|
||||
title="No collection selected"
|
||||
description="Select a collection from the sidebar to view its documents"
|
||||
icon={
|
||||
<svg class="w-12 h-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
|
||||
</svg>
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
}
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-gray-900">{activeCollection()?.name}</h2>
|
||||
<p class="text-sm text-gray-500">{activeCollection()?.slug}</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={() => props.onDeleteCollection(activeCollection()!.id)}>
|
||||
Delete Collection
|
||||
</Button>
|
||||
<Button onClick={() => handleOpenDocumentModal()}>
|
||||
Add Document
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={props.collectionMessage}>
|
||||
<div class="message message-neutral">{props.collectionMessage}</div>
|
||||
</Show>
|
||||
|
||||
<Card class="flex-1 overflow-hidden flex flex-col">
|
||||
<Show
|
||||
when={props.documents.length > 0}
|
||||
<div class="grid gap-5" style="grid-template-columns: minmax(0, 17rem) minmax(0, 1fr)">
|
||||
{/* collection list */}
|
||||
<div class="card card-flush">
|
||||
<div
|
||||
class="card-header"
|
||||
style="padding:1rem 1rem 0.75rem;margin-bottom:0;border-bottom:1px solid var(--border)"
|
||||
>
|
||||
<span class="card-header-title">Collections</span>
|
||||
</div>
|
||||
<div class="p-1.5">
|
||||
<For
|
||||
each={props.collections}
|
||||
fallback={
|
||||
<div class="flex-1 flex items-center justify-center p-8">
|
||||
<EmptyState
|
||||
title="No documents yet"
|
||||
description="This collection is empty. Add your first document to get started."
|
||||
action={<Button onClick={() => handleOpenDocumentModal()}>Add Document</Button>}
|
||||
/>
|
||||
<div class="p-4 text-center">
|
||||
<p class="text-xs text-text-3">No collections yet</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="overflow-auto">
|
||||
<Table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="w-1/3">ID</th>
|
||||
<th>Data</th>
|
||||
<th class="w-48">Created At</th>
|
||||
<th class="w-24 text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={props.documents}>
|
||||
{(doc) => (
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="font-mono text-xs text-gray-500">{doc.id}</td>
|
||||
<td>
|
||||
<div class="max-w-md truncate text-sm">
|
||||
{JSON.stringify(doc.data)}
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-sm text-gray-500">{props.formatDate(doc.created_at)}</td>
|
||||
<td class="text-right">
|
||||
<div class="flex justify-end gap-1">
|
||||
<button
|
||||
onClick={() => handleOpenDocumentModal(doc)}
|
||||
class="p-1 text-gray-400 hover:text-blue-600 transition-colors"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => props.onDeleteDocument(doc.id)}
|
||||
class="p-1 text-gray-400 hover:text-red-600 transition-colors"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</Table>
|
||||
{(collection) => (
|
||||
<button
|
||||
class={`nav-item w-full ${collection.id === props.selectedCollectionID ? "active" : ""}`}
|
||||
onClick={() => props.onSelectCollection(collection.id)}
|
||||
>
|
||||
<IconCollections class="w-4 h-4" />
|
||||
<span class="flex-1 truncate">{collection.name}</span>
|
||||
<Show when={props.canUpdate}>
|
||||
<span
|
||||
class="icon-btn"
|
||||
role="button"
|
||||
title="Delete collection"
|
||||
aria-label={`Delete ${collection.name}`}
|
||||
style="width:1.5rem;height:1.5rem"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
props.onDeleteCollection(collection.id);
|
||||
}}
|
||||
>
|
||||
<IconTrash class="w-3.5 h-3.5" />
|
||||
</span>
|
||||
</Show>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* documents */}
|
||||
<div class="card card-flush" style="min-height:28rem">
|
||||
<Show
|
||||
when={activeCollection()}
|
||||
fallback={
|
||||
<div class="empty-state" style="min-height:28rem">
|
||||
<div class="empty-state-icon">
|
||||
<IconCollections class="w-5 h-5" />
|
||||
</div>
|
||||
<p class="empty-state-title">Select a collection</p>
|
||||
<p class="empty-state-description">
|
||||
Choose a collection on the left, or create one to start adding documents.
|
||||
</p>
|
||||
</div>
|
||||
</Show>
|
||||
</Card>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<div
|
||||
class="card-header"
|
||||
style="padding:1rem 1.25rem;margin-bottom:0;border-bottom:1px solid var(--border)"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3 w-full">
|
||||
<div class="min-w-0">
|
||||
<span class="card-header-title">{activeCollection()!.name}</span>
|
||||
<span class="card-header-description">
|
||||
<code>{activeCollection()!.slug}</code>
|
||||
<Show when={activeCollection()!.description}>
|
||||
{" · "}{activeCollection()!.description}
|
||||
</Show>
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-secondary btn-sm"
|
||||
onClick={openNewDoc}
|
||||
disabled={!props.canUpdate}
|
||||
>
|
||||
<IconPlus class="w-3.5 h-3.5" />
|
||||
New document
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-container">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Document</th>
|
||||
<th style="width:1%">Updated</th>
|
||||
<th style="width:1%" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For
|
||||
each={props.documents}
|
||||
fallback={
|
||||
<tr>
|
||||
<td colspan={3} class="py-10 text-center text-text-3">
|
||||
No documents yet. Create one as a JSON object.
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
>
|
||||
{(doc) => (
|
||||
<tr class="clickable" onClick={() => openEditDoc(doc)}>
|
||||
<td>
|
||||
<div class="flex items-center gap-2.5 min-w-0">
|
||||
<IconFile class="w-4 h-4 text-text-3 flex-shrink-0" />
|
||||
<div class="min-w-0">
|
||||
<div class="font-mono text-xs text-text-1">{doc.id}</div>
|
||||
<div class="text-xs text-text-3 truncate" style="max-width:30rem">
|
||||
{JSON.stringify(doc.data)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-xs text-text-3 whitespace-nowrap">
|
||||
{props.formatDate(doc.updated_at)}
|
||||
</td>
|
||||
<td>
|
||||
<IconChevronRight class="w-4 h-4 text-text-3" />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Create Collection Modal */}
|
||||
<Modal
|
||||
open={showCreateCollectionModal()}
|
||||
onClose={() => setShowCreateCollectionModal(false)}
|
||||
title="Create New Collection"
|
||||
{/* create collection modal */}
|
||||
<Modal
|
||||
open={createOpen()}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
title="Create collection"
|
||||
description="Collections store JSON documents scoped to a project."
|
||||
>
|
||||
<form class="space-y-4" onSubmit={handleCreateCollection}>
|
||||
<form onSubmit={props.onCreateCollection} class="space-y-4">
|
||||
<Input
|
||||
label="Collection Name"
|
||||
placeholder="Users, Products, etc."
|
||||
label="Collection name"
|
||||
placeholder="Posts"
|
||||
value={props.collectionInput.name}
|
||||
onInput={(e) => {
|
||||
const name = e.currentTarget.value;
|
||||
props.onCollectionInputChange('name', name);
|
||||
const slug = name.toLowerCase().trim().replace(/[^a-z0-9\s-]/g, '').replace(/\s+/g, '-').replace(/-+/g, '-');
|
||||
props.onCollectionInputChange('slug', slug);
|
||||
}}
|
||||
onInput={(e) => props.onCollectionInputChange("name", e.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Collection Slug"
|
||||
placeholder="users"
|
||||
label="Slug"
|
||||
placeholder="posts"
|
||||
value={props.collectionInput.slug}
|
||||
onInput={(e) => props.onCollectionInputChange('slug', e.currentTarget.value)}
|
||||
onInput={(e) => props.onCollectionInputChange("slug", e.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
<Textarea
|
||||
<Input
|
||||
label="Description"
|
||||
placeholder="What's in this collection?"
|
||||
placeholder="Optional"
|
||||
value={props.collectionInput.description}
|
||||
onInput={(e) => props.onCollectionInputChange('description', e.currentTarget.value)}
|
||||
rows={3}
|
||||
onInput={(e) => props.onCollectionInputChange("description", e.currentTarget.value)}
|
||||
/>
|
||||
<div class="flex gap-3 justify-end pt-4">
|
||||
<Button type="button" variant="ghost" onClick={() => setShowCreateCollectionModal(false)}>
|
||||
<ModalFooter>
|
||||
<button type="button" class="btn btn-ghost" onClick={() => setCreateOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" disabled={props.collectionPending}>
|
||||
{props.collectionPending ? "Creating..." : "Create Collection"}
|
||||
</Button>
|
||||
</div>
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary" disabled={props.collectionPending}>
|
||||
Create collection
|
||||
</button>
|
||||
</ModalFooter>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
{/* Document Editor Modal */}
|
||||
<Modal
|
||||
open={showDocumentModal()}
|
||||
onClose={() => setShowDocumentModal(false)}
|
||||
title={editingDocument() ? "Edit Document" : "New Document"}
|
||||
{/* document editor modal */}
|
||||
<Modal
|
||||
open={docEditorOpen()}
|
||||
onClose={() => setDocEditorOpen(false)}
|
||||
title={editingDoc() ? "Edit document" : "New document"}
|
||||
description="Documents are arbitrary JSON objects validated against the collection schema when one is set."
|
||||
size="lg"
|
||||
>
|
||||
<form class="space-y-4" onSubmit={handleSaveDocument}>
|
||||
<div class="space-y-2">
|
||||
<label class="block text-sm font-medium text-gray-700">JSON Data</label>
|
||||
<div class="font-mono text-sm border rounded-md overflow-hidden bg-gray-50">
|
||||
<textarea
|
||||
value={documentData()}
|
||||
onInput={(e) => setDocumentData(e.currentTarget.value)}
|
||||
class="w-full h-64 p-4 bg-transparent outline-none focus:ring-2 focus:ring-blue-500"
|
||||
spellcheck={false}
|
||||
/>
|
||||
<form onSubmit={submitDoc} class="space-y-4">
|
||||
<Show when={editingDoc()}>
|
||||
<div class="flex items-center gap-2 text-xs text-text-3">
|
||||
<Badge variant="neutral"><code class="border-0 bg-transparent p-0">{editingDoc()!.id}</code></Badge>
|
||||
<span>created {props.formatDate(editingDoc()!.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3 justify-end pt-4">
|
||||
<Button type="button" variant="ghost" onClick={() => setShowDocumentModal(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" disabled={props.documentPending}>
|
||||
{props.documentPending ? "Saving..." : "Save Document"}
|
||||
</Button>
|
||||
</div>
|
||||
</Show>
|
||||
<Textarea
|
||||
label="Document data (JSON)"
|
||||
class="font-mono"
|
||||
style="min-height:16rem;font-size:0.75rem"
|
||||
value={docJson()}
|
||||
onInput={(e) => {
|
||||
setDocJson(e.currentTarget.value);
|
||||
setDocError("");
|
||||
}}
|
||||
spellcheck={false}
|
||||
required
|
||||
/>
|
||||
<Show when={docError()}>
|
||||
<div class="message message-error">{docError()}</div>
|
||||
</Show>
|
||||
<ModalFooter align="between">
|
||||
<Show when={editingDoc()} fallback={<span />}>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-danger"
|
||||
onClick={() => {
|
||||
const doc = editingDoc();
|
||||
if (doc) {
|
||||
setDocEditorOpen(false);
|
||||
props.onDeleteDocument(doc.id);
|
||||
}
|
||||
}}
|
||||
disabled={!props.canUpdate || props.documentPending}
|
||||
>
|
||||
Delete document
|
||||
</button>
|
||||
</Show>
|
||||
<div class="flex gap-2">
|
||||
<button type="button" class="btn btn-ghost" onClick={() => setDocEditorOpen(false)}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary"
|
||||
disabled={!props.canUpdate || props.documentPending}
|
||||
>
|
||||
{editingDoc() ? "Save changes" : "Create document"}
|
||||
</button>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
</form>
|
||||
</Modal>
|
||||
</div>
|
||||
|
||||
@@ -1,223 +0,0 @@
|
||||
import { For } from "solid-js";
|
||||
import { Logo } from "../components/Logo";
|
||||
import { Button } from "../components/Button";
|
||||
import { Card, CardHeader, StatCard } from "../components/Card";
|
||||
import { Input, Textarea, Select } from "../components/Input";
|
||||
import { Badge, StatusBadge } from "../components/Badge";
|
||||
import { Message, Loading, Skeleton } from "../components/Message";
|
||||
import { Table } from "../components/Table";
|
||||
|
||||
export function EnhancementsShowcase() {
|
||||
const sampleData = [
|
||||
{ id: 1, name: "Project Alpha", status: "active", users: 42 },
|
||||
{ id: 2, name: "Project Beta", status: "pending", users: 18 },
|
||||
{ id: 3, name: "Project Gamma", status: "completed", users: 156 },
|
||||
];
|
||||
|
||||
const columns = [
|
||||
{ key: "name", header: "Project Name" },
|
||||
{ key: "status", header: "Status", render: (val: string) => <StatusBadge status={val as any} /> },
|
||||
{ key: "users", header: "Users", align: "right" as const },
|
||||
];
|
||||
|
||||
return (
|
||||
<div class="min-h-screen bg-bg-main p-8">
|
||||
<div class="max-w-7xl mx-auto space-y-12">
|
||||
{/* Header */}
|
||||
<div class="text-center space-y-4 animate-fade-in">
|
||||
<div class="flex justify-center mb-6">
|
||||
<Logo size="lg" animated />
|
||||
</div>
|
||||
<h1 class="text-5xl font-display font-extrabold">
|
||||
Enhanced Design System
|
||||
</h1>
|
||||
<p class="text-lg text-text-secondary max-w-2xl mx-auto">
|
||||
A distinctive, production-grade UI system with sophisticated micro-interactions,
|
||||
refined typography, and purposeful animations.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Typography Section */}
|
||||
<section class="space-y-6 animate-slide-up stagger-1">
|
||||
<div class="section-eyebrow">Typography</div>
|
||||
<h2 class="section-title">Distinctive Font System</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<Card class="card-premium">
|
||||
<h3 class="font-display text-2xl font-bold mb-2">Syne Display</h3>
|
||||
<p class="text-text-secondary text-sm">
|
||||
Bold, geometric display font for headings and brand elements
|
||||
</p>
|
||||
</Card>
|
||||
<Card class="card-premium">
|
||||
<h3 class="font-sans text-2xl font-semibold mb-2">DM Sans Body</h3>
|
||||
<p class="text-text-secondary text-sm">
|
||||
Clean, readable sans-serif for body text and UI elements
|
||||
</p>
|
||||
</Card>
|
||||
<Card class="card-premium">
|
||||
<h3 class="font-mono text-xl font-medium mb-2">JetBrains Mono</h3>
|
||||
<p class="text-text-secondary text-sm">
|
||||
Professional monospace for code and technical content
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Buttons Section */}
|
||||
<section class="space-y-6 animate-slide-up stagger-2">
|
||||
<div class="section-eyebrow">Interactive Elements</div>
|
||||
<h2 class="section-title">Enhanced Buttons</h2>
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<Button variant="primary" class="btn-glow">
|
||||
Primary Action
|
||||
</Button>
|
||||
<Button variant="secondary">
|
||||
Secondary Action
|
||||
</Button>
|
||||
<Button variant="ghost">
|
||||
Ghost Button
|
||||
</Button>
|
||||
<Button variant="danger">
|
||||
Danger Action
|
||||
</Button>
|
||||
<Button variant="primary" loading>
|
||||
Loading...
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Cards Section */}
|
||||
<section class="space-y-6 animate-slide-up stagger-3">
|
||||
<div class="section-eyebrow">Data Display</div>
|
||||
<h2 class="section-title">Stat Cards with Animations</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
<div class="stat-card-enhanced">
|
||||
<p class="stat-label">Total Users</p>
|
||||
<p class="stat-value-animated">2,847</p>
|
||||
<p class="text-xs text-success mt-2">↑ 12.5% from last month</p>
|
||||
</div>
|
||||
<div class="stat-card-enhanced">
|
||||
<p class="stat-label">Active Projects</p>
|
||||
<p class="stat-value-animated">42</p>
|
||||
<p class="text-xs text-accent mt-2">→ Stable</p>
|
||||
</div>
|
||||
<div class="stat-card-enhanced">
|
||||
<p class="stat-label">Storage Used</p>
|
||||
<p class="stat-value-animated">156 GB</p>
|
||||
<p class="text-xs text-warning mt-2">↑ 8.2% from last month</p>
|
||||
</div>
|
||||
<div class="stat-card-enhanced">
|
||||
<p class="stat-label">API Calls</p>
|
||||
<p class="stat-value-animated">1.2M</p>
|
||||
<p class="text-xs text-success mt-2">↑ 24.1% from last month</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Forms Section */}
|
||||
<section class="space-y-6 animate-slide-up stagger-4">
|
||||
<div class="section-eyebrow">Form Elements</div>
|
||||
<h2 class="section-title">Enhanced Inputs</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<Card class="card-premium">
|
||||
<div class="space-y-4">
|
||||
<div class="input-enhanced">
|
||||
<Input label="Project Name" placeholder="Enter project name..." />
|
||||
</div>
|
||||
<div class="input-enhanced">
|
||||
<Select label="Project Type" options={[
|
||||
{ value: "web", label: "Web Application" },
|
||||
{ value: "mobile", label: "Mobile App" },
|
||||
{ value: "api", label: "API Service" },
|
||||
]} />
|
||||
</div>
|
||||
<div class="input-enhanced">
|
||||
<Textarea label="Description" placeholder="Describe your project..." />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card class="card-premium">
|
||||
<CardHeader
|
||||
eyebrow="Form States"
|
||||
title="Interactive Feedback"
|
||||
description="Hover and focus states with smooth animations"
|
||||
/>
|
||||
<div class="space-y-4 mt-4">
|
||||
<Badge variant="primary" class="badge-animated">Active</Badge>
|
||||
<Badge variant="success" class="badge-animated">Completed</Badge>
|
||||
<Badge variant="warning" class="badge-animated">Pending</Badge>
|
||||
<Badge variant="error" class="badge-animated">Error</Badge>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Messages Section */}
|
||||
<section class="space-y-6 animate-slide-up stagger-5">
|
||||
<div class="section-eyebrow">Feedback</div>
|
||||
<h2 class="section-title">Messages & Alerts</h2>
|
||||
<div class="space-y-4">
|
||||
<Message variant="success" title="Success!">
|
||||
Your project has been created successfully.
|
||||
</Message>
|
||||
<Message variant="info" title="Information">
|
||||
New features are available in the latest update.
|
||||
</Message>
|
||||
<Message variant="warning" title="Warning">
|
||||
Your storage is almost full. Consider upgrading your plan.
|
||||
</Message>
|
||||
<Message variant="error" title="Error">
|
||||
Failed to connect to the server. Please try again.
|
||||
</Message>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Table Section */}
|
||||
<section class="space-y-6 animate-slide-up stagger-6">
|
||||
<div class="section-eyebrow">Data Tables</div>
|
||||
<h2 class="section-title">Enhanced Table Component</h2>
|
||||
<Card class="card-premium">
|
||||
<div class="table-enhanced">
|
||||
<Table
|
||||
columns={columns}
|
||||
data={sampleData}
|
||||
rowKey={(row) => row.id}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* Loading States */}
|
||||
<section class="space-y-6 animate-slide-up">
|
||||
<div class="section-eyebrow">Loading States</div>
|
||||
<h2 class="section-title">Skeleton & Spinners</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<Card>
|
||||
<Skeleton width="60%" height="20px" class="mb-3" />
|
||||
<Skeleton width="100%" height="14px" class="mb-2" />
|
||||
<Skeleton width="100%" height="14px" class="mb-2" />
|
||||
<Skeleton width="80%" height="14px" />
|
||||
</Card>
|
||||
<Card class="flex items-center justify-center">
|
||||
<Loading text="Loading data..." size="lg" />
|
||||
</Card>
|
||||
<Card class="flex items-center justify-center">
|
||||
<div class="loading-dots">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Footer */}
|
||||
<footer class="text-center py-12 border-t border-border">
|
||||
<p class="text-text-muted text-sm">
|
||||
PRIMORA Enhanced Design System — Production-grade UI components
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { Show } from "solid-js";
|
||||
import { Input } from "../components/Input";
|
||||
import {
|
||||
LogoMark,
|
||||
LogoGithub,
|
||||
LogoGoogle,
|
||||
LogoDiscord,
|
||||
LogoMicrosoft,
|
||||
} from "../components/Icons";
|
||||
|
||||
type AuthMode = "sign-in" | "sign-up";
|
||||
type Provider = "github" | "google" | "discord" | "microsoft";
|
||||
|
||||
interface LoginPageProps {
|
||||
mode: AuthMode;
|
||||
email: string;
|
||||
password: string;
|
||||
name: string;
|
||||
authMessage: string;
|
||||
authPending: boolean;
|
||||
onModeChange: (mode: AuthMode) => void;
|
||||
onEmailChange: (value: string) => void;
|
||||
onPasswordChange: (value: string) => void;
|
||||
onNameChange: (value: string) => void;
|
||||
onSubmit: (event: SubmitEvent) => void;
|
||||
onSocial: (provider: Provider) => void;
|
||||
onTryDemo?: () => void;
|
||||
}
|
||||
|
||||
const socialProviders: { id: Provider; label: string; logo: (p: { class?: string }) => import("solid-js").JSX.Element }[] = [
|
||||
{ id: "github", label: "GitHub", logo: LogoGithub },
|
||||
{ id: "google", label: "Google", logo: LogoGoogle },
|
||||
{ id: "discord", label: "Discord", logo: LogoDiscord },
|
||||
{ id: "microsoft", label: "Microsoft", logo: LogoMicrosoft },
|
||||
];
|
||||
|
||||
export function LoginPage(props: LoginPageProps) {
|
||||
const isSignIn = () => props.mode === "sign-in";
|
||||
|
||||
return (
|
||||
<div class="auth-wrap">
|
||||
{/* brand panel */}
|
||||
<aside class="auth-brand" aria-hidden="true">
|
||||
<div class="auth-brand-grid" />
|
||||
<div class="auth-brand-inner">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<LogoMark class="w-6 h-6 text-accent" />
|
||||
<span class="font-semibold tracking-tight" style="color: var(--text-1)">Primora</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="auth-brand-inner">
|
||||
<p class="auth-tagline">The console for your self-hosted backend.</p>
|
||||
<p class="mt-3" style="font-size: 0.875rem; color: var(--text-3); max-width: 22rem;">
|
||||
Storage, documents, credentials and audit — one Postgres, one API, your hardware.
|
||||
</p>
|
||||
</div>
|
||||
<div class="auth-points">
|
||||
<div class="auth-point">Every mutation lands in the audit log</div>
|
||||
<div class="auth-point">Generated TypeScript client, straight from OpenAPI</div>
|
||||
<div class="auth-point">No hosted tier — your data never leaves the box</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* form panel */}
|
||||
<main class="auth-panel">
|
||||
<div class="auth-card animate-scale-in">
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center gap-2.5 mb-6" style="min-width: 0">
|
||||
<LogoMark class="w-5 h-5 text-accent" />
|
||||
<span class="font-semibold tracking-tight" style="color: var(--text-1)">Primora</span>
|
||||
</div>
|
||||
<h1 style="font-size: 1.25rem; letter-spacing: -0.02em;">
|
||||
{isSignIn() ? "Sign in" : "Create your account"}
|
||||
</h1>
|
||||
<p class="mt-1" style="font-size: 0.8125rem; color: var(--text-3)">
|
||||
{isSignIn()
|
||||
? "Access your workspace"
|
||||
: "One account for the whole workspace"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* mode toggle */}
|
||||
<div class="seg mb-6" role="tablist" aria-label="Authentication mode">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={isSignIn()}
|
||||
class={`seg-item ${isSignIn() ? "active" : ""}`}
|
||||
onClick={() => props.onModeChange("sign-in")}
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={!isSignIn()}
|
||||
class={`seg-item ${!isSignIn() ? "active" : ""}`}
|
||||
onClick={() => props.onModeChange("sign-up")}
|
||||
>
|
||||
Sign up
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={props.onSubmit} class="space-y-4">
|
||||
<Show when={!isSignIn()}>
|
||||
<Input
|
||||
label="Name"
|
||||
type="text"
|
||||
placeholder="Ada Lovelace"
|
||||
value={props.name}
|
||||
onInput={(e) => props.onNameChange(e.currentTarget.value)}
|
||||
autocomplete="name"
|
||||
required
|
||||
/>
|
||||
</Show>
|
||||
<Input
|
||||
label="Email"
|
||||
type="email"
|
||||
placeholder="[email protected]"
|
||||
value={props.email}
|
||||
onInput={(e) => props.onEmailChange(e.currentTarget.value)}
|
||||
autocomplete="email"
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Password"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
value={props.password}
|
||||
onInput={(e) => props.onPasswordChange(e.currentTarget.value)}
|
||||
autocomplete={isSignIn() ? "current-password" : "new-password"}
|
||||
required
|
||||
/>
|
||||
|
||||
<Show when={props.authMessage}>
|
||||
<div class="message message-neutral">{props.authMessage}</div>
|
||||
</Show>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary w-full"
|
||||
disabled={props.authPending}
|
||||
>
|
||||
<Show
|
||||
when={props.authPending}
|
||||
fallback={isSignIn() ? "Sign in" : "Create account"}
|
||||
>
|
||||
<span class="spinner" /> Working…
|
||||
</Show>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="divider my-6">or continue with</div>
|
||||
|
||||
<div class="grid grid-cols-4 gap-2">
|
||||
{socialProviders.map((p) => (
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary btn-icon w-full"
|
||||
onClick={() => props.onSocial(p.id)}
|
||||
disabled={props.authPending}
|
||||
title={`Continue with ${p.label}`}
|
||||
aria-label={`Continue with ${p.label}`}
|
||||
>
|
||||
<p.logo class="w-4 h-4" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Show when={props.onTryDemo}>
|
||||
<div class="divider my-6" />
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost w-full"
|
||||
style="color: var(--text-3)"
|
||||
onClick={props.onTryDemo}
|
||||
>
|
||||
No backend yet — <span style="color: var(--accent)">launch demo mode</span>
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,385 +1,435 @@
|
||||
import { Show, For, createSignal } from "solid-js";
|
||||
import { Button, Card, Input, Select, Badge, Table, EmptyState, Message, Modal, Tabs, TabPanel } from "../components";
|
||||
import type { OrganizationMember, OrganizationInvitation, ProjectMember } from "@primora/api-client";
|
||||
import { For, Show, createMemo, createSignal } from "solid-js";
|
||||
import type {
|
||||
OrganizationInvitation,
|
||||
OrganizationMember,
|
||||
ProjectMember,
|
||||
} from "@primora/api-client";
|
||||
import { Badge } from "../components/Badge";
|
||||
import { Modal, ModalFooter } from "../components/Modal";
|
||||
import { Input, Select } from "../components/Input";
|
||||
import {
|
||||
IconPlus,
|
||||
IconMembers,
|
||||
IconTrash,
|
||||
IconMail,
|
||||
} from "../components/Icons";
|
||||
|
||||
interface InvitationInput {
|
||||
email: string;
|
||||
orgRole: string;
|
||||
attachProject: boolean;
|
||||
projectRole: string;
|
||||
}
|
||||
|
||||
interface MembersPageProps {
|
||||
organizationMembers?: OrganizationMember[];
|
||||
organizationInvitations?: OrganizationInvitation[];
|
||||
projectMembers?: ProjectMember[];
|
||||
invitationInput: {
|
||||
email: string;
|
||||
orgRole: string;
|
||||
attachProject: boolean;
|
||||
projectRole: string;
|
||||
};
|
||||
invitationInput: InvitationInput;
|
||||
inviteMessage: string;
|
||||
memberMessage: string;
|
||||
invitationPending: boolean;
|
||||
membersPending: boolean;
|
||||
canManageMembers: boolean;
|
||||
hasActiveProject: boolean;
|
||||
onInvitationInputChange: (field: string, value: any) => void;
|
||||
onSendInvitation: (e: SubmitEvent) => void;
|
||||
onInvitationInputChange: (field: keyof InvitationInput, value: string | boolean) => void;
|
||||
onSendInvitation: (event: SubmitEvent) => void;
|
||||
onRevokeInvitation: (id: string) => void;
|
||||
onRemoveMember: (id: string, type: 'org' | 'project') => void;
|
||||
onUpdateMemberRole: (id: string, role: string, type: 'org' | 'project') => void;
|
||||
onRemoveMember: (id: string, type: "org" | "project") => void;
|
||||
onUpdateMemberRole: (id: string, role: string, type: "org" | "project") => void;
|
||||
}
|
||||
|
||||
const orgRoles = ["owner", "admin", "member"] as const;
|
||||
const projectRoles = ["admin", "developer", "viewer"] as const;
|
||||
|
||||
function roleVariant(role: string): "primary" | "success" | "warning" | "neutral" {
|
||||
switch (role) {
|
||||
case "owner":
|
||||
return "primary";
|
||||
case "admin":
|
||||
return "success";
|
||||
case "developer":
|
||||
return "warning";
|
||||
default:
|
||||
return "neutral";
|
||||
}
|
||||
}
|
||||
|
||||
function initials(name?: string, email?: string): string {
|
||||
const src = name || email || "?";
|
||||
return src
|
||||
.split(/[\s@.]+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((s) => s[0]?.toUpperCase())
|
||||
.join("");
|
||||
}
|
||||
|
||||
function formatDate(value?: string | null): string {
|
||||
if (!value) return "—";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "—";
|
||||
return date.toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
export function MembersPage(props: MembersPageProps) {
|
||||
const [showInviteModal, setShowInviteModal] = createSignal(false);
|
||||
const [activeTab, setActiveTab] = createSignal<'organization' | 'project'>('organization');
|
||||
const [searchQuery, setSearchQuery] = createSignal("");
|
||||
const [tab, setTab] = createSignal<"org" | "project">("org");
|
||||
const [inviteOpen, setInviteOpen] = createSignal(false);
|
||||
const [query, setQuery] = createSignal("");
|
||||
|
||||
const filteredOrgMembers = () => {
|
||||
const query = searchQuery().toLowerCase();
|
||||
if (!query || !props.organizationMembers) return props.organizationMembers || [];
|
||||
return props.organizationMembers.filter(m =>
|
||||
m.user_name?.toLowerCase().includes(query) ||
|
||||
m.user_email?.toLowerCase().includes(query)
|
||||
const filteredOrgMembers = createMemo(() => {
|
||||
const q = query().toLowerCase();
|
||||
const list = props.organizationMembers ?? [];
|
||||
if (!q) return list;
|
||||
return list.filter(
|
||||
(m) =>
|
||||
m.name.toLowerCase().includes(q) || m.email.toLowerCase().includes(q),
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
const filteredProjectMembers = () => {
|
||||
const query = searchQuery().toLowerCase();
|
||||
if (!query || !props.projectMembers) return props.projectMembers || [];
|
||||
return props.projectMembers.filter(m =>
|
||||
m.user_name?.toLowerCase().includes(query) ||
|
||||
m.user_email?.toLowerCase().includes(query)
|
||||
const filteredProjectMembers = createMemo(() => {
|
||||
const q = query().toLowerCase();
|
||||
const list = props.projectMembers ?? [];
|
||||
if (!q) return list;
|
||||
return list.filter(
|
||||
(m) =>
|
||||
m.name.toLowerCase().includes(q) || m.email.toLowerCase().includes(q),
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
const handleInviteSubmit = (e: SubmitEvent) => {
|
||||
const pendingInvitations = createMemo(() =>
|
||||
(props.organizationInvitations ?? []).filter((i) => i.status === "pending"),
|
||||
);
|
||||
|
||||
const handleInvite = (e: SubmitEvent) => {
|
||||
props.onSendInvitation(e);
|
||||
setShowInviteModal(false);
|
||||
setInviteOpen(false);
|
||||
};
|
||||
|
||||
const getRoleBadgeVariant = (role: string) => {
|
||||
switch (role) {
|
||||
case 'owner': return 'primary';
|
||||
case 'admin': return 'success';
|
||||
default: return 'secondary';
|
||||
}
|
||||
};
|
||||
const MemberRow = (p: {
|
||||
member: OrganizationMember | ProjectMember;
|
||||
type: "org" | "project";
|
||||
roles: readonly string[];
|
||||
}) => (
|
||||
<tr>
|
||||
<td>
|
||||
<div class="flex items-center gap-2.5">
|
||||
<span
|
||||
class="avatar"
|
||||
style="width:1.75rem;height:1.75rem;font-size:0.625rem"
|
||||
>
|
||||
{initials(p.member.name, p.member.email)}
|
||||
</span>
|
||||
<div>
|
||||
<div class="font-medium text-text-1">{p.member.name || "Unnamed"}</div>
|
||||
<div class="text-xs text-text-3">{p.member.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<Badge variant={p.member.email_verified ? "success" : "warning"}>
|
||||
<span class="badge-dot" />
|
||||
{p.member.email_verified ? "Verified" : "Unverified"}
|
||||
</Badge>
|
||||
</td>
|
||||
<td>
|
||||
<Show
|
||||
when={props.canManageMembers}
|
||||
fallback={<Badge variant={roleVariant(p.member.role)}>{p.member.role}</Badge>}
|
||||
>
|
||||
<select
|
||||
class="select input-sm"
|
||||
style="width:8.5rem"
|
||||
value={p.member.role}
|
||||
onChange={(e) =>
|
||||
props.onUpdateMemberRole(p.member.user_id, e.currentTarget.value, p.type)
|
||||
}
|
||||
disabled={props.membersPending}
|
||||
aria-label={`Role for ${p.member.email}`}
|
||||
>
|
||||
<For each={p.roles}>
|
||||
{(role) => <option value={role}>{role}</option>}
|
||||
</For>
|
||||
</select>
|
||||
</Show>
|
||||
</td>
|
||||
<td class="text-text-3">{formatDate(p.member.joined_at)}</td>
|
||||
<td style="width:1%">
|
||||
<Show when={props.canManageMembers}>
|
||||
<button
|
||||
class="icon-btn"
|
||||
title="Remove member"
|
||||
aria-label={`Remove ${p.member.email}`}
|
||||
onClick={() => props.onRemoveMember(p.member.user_id, p.type)}
|
||||
disabled={props.membersPending}
|
||||
>
|
||||
<IconTrash class="w-4 h-4" />
|
||||
</button>
|
||||
</Show>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
|
||||
return (
|
||||
<div class="space-y-6">
|
||||
{/* Header */}
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900">Team Members</h1>
|
||||
<p class="text-gray-600 mt-1">Manage organization and project access</p>
|
||||
<h1 class="page-title">Members</h1>
|
||||
<p class="page-description">
|
||||
Organization members share workspace access; project members get scoped roles.
|
||||
</p>
|
||||
</div>
|
||||
<div class="page-actions">
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
onClick={() => setInviteOpen(true)}
|
||||
disabled={!props.canManageMembers}
|
||||
>
|
||||
<IconPlus class="w-4 h-4" />
|
||||
Invite member
|
||||
</button>
|
||||
</div>
|
||||
<Show when={props.canManageMembers}>
|
||||
<Button onClick={() => setShowInviteModal(true)}>
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z" />
|
||||
</svg>
|
||||
Invite Member
|
||||
</Button>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs
|
||||
tabs={[
|
||||
{ id: 'organization', label: 'Organization Members' },
|
||||
{ id: 'project', label: 'Project Members', disabled: !props.hasActiveProject },
|
||||
]}
|
||||
activeTab={activeTab()}
|
||||
onChange={(id) => setActiveTab(id as 'organization' | 'project')}
|
||||
/>
|
||||
<Show when={props.inviteMessage}>
|
||||
<div class="message message-info">{props.inviteMessage}</div>
|
||||
</Show>
|
||||
<Show when={props.memberMessage}>
|
||||
<div class="message message-neutral">{props.memberMessage}</div>
|
||||
</Show>
|
||||
|
||||
{/* Search Bar */}
|
||||
<Card class="p-4">
|
||||
<div class="flex items-center justify-between gap-3 flex-wrap">
|
||||
<div class="tabs">
|
||||
<button
|
||||
class={`tab ${tab() === "org" ? "active" : ""}`}
|
||||
onClick={() => setTab("org")}
|
||||
>
|
||||
Organization
|
||||
<span class="nav-badge ml-1.5">{(props.organizationMembers ?? []).length}</span>
|
||||
</button>
|
||||
<button
|
||||
class={`tab ${tab() === "project" ? "active" : ""}`}
|
||||
onClick={() => setTab("project")}
|
||||
disabled={!props.hasActiveProject}
|
||||
>
|
||||
Project
|
||||
<span class="nav-badge ml-1.5">{(props.projectMembers ?? []).length}</span>
|
||||
</button>
|
||||
</div>
|
||||
<Input
|
||||
placeholder="Search members by name or email..."
|
||||
value={searchQuery()}
|
||||
onInput={(e) => setSearchQuery(e.currentTarget.value)}
|
||||
class="w-full"
|
||||
placeholder="Filter members…"
|
||||
value={query()}
|
||||
onInput={(e) => setQuery(e.currentTarget.value)}
|
||||
class="input-sm"
|
||||
style="width:14rem"
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Organization Members Tab */}
|
||||
<Show when={activeTab() === 'organization'}>
|
||||
<div class="space-y-6">
|
||||
{/* Pending Invitations */}
|
||||
<Show when={(props.organizationInvitations?.length ?? 0) > 0}>
|
||||
<Card class="p-6">
|
||||
<h2 class="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<svg class="w-5 h-5 text-yellow-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
Pending Invitations
|
||||
</h2>
|
||||
<div class="space-y-3">
|
||||
<For each={props.organizationInvitations}>
|
||||
{(invitation) => (
|
||||
<div class="flex items-center justify-between p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||
<div class="flex-1">
|
||||
<p class="font-medium text-gray-900">{invitation.email}</p>
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<Badge variant="secondary">{invitation.org_role}</Badge>
|
||||
<Show when={invitation.project_id}>
|
||||
<Badge variant="secondary">Project: {invitation.project_role}</Badge>
|
||||
</Show>
|
||||
<span class="text-xs text-gray-500">
|
||||
Invited {new Date(invitation.created_at).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={props.canManageMembers}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => props.onRevokeInvitation(invitation.id)}
|
||||
disabled={props.invitationPending}
|
||||
>
|
||||
Revoke
|
||||
</Button>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Card>
|
||||
</Show>
|
||||
|
||||
{/* Members List */}
|
||||
<Card>
|
||||
<Show
|
||||
when={filteredOrgMembers().length > 0}
|
||||
fallback={
|
||||
<div class="p-12">
|
||||
<EmptyState
|
||||
icon={
|
||||
<svg class="w-16 h-16" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</svg>
|
||||
}
|
||||
title="No members found"
|
||||
description={searchQuery() ? "Try adjusting your search" : "Invite team members to collaborate"}
|
||||
/>
|
||||
<div class="card card-flush">
|
||||
<Show when={tab() === "org"}>
|
||||
<Show
|
||||
when={filteredOrgMembers().length > 0}
|
||||
fallback={
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-icon">
|
||||
<IconMembers class="w-5 h-5" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Table>
|
||||
<p class="empty-state-title">No members found</p>
|
||||
<p class="empty-state-description">
|
||||
Invite teammates to collaborate on this organization.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="table-container">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Member</th>
|
||||
<th>Email</th>
|
||||
<th>Role</th>
|
||||
<th>Joined</th>
|
||||
<th class="text-right">Actions</th>
|
||||
<th style="width:1%" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={filteredOrgMembers()}>
|
||||
{(member) => (
|
||||
<tr>
|
||||
<td>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-full bg-gradient-to-br from-blue-500 to-purple-500 flex items-center justify-center text-white font-semibold">
|
||||
{member.user_name?.charAt(0).toUpperCase() ?? '?'}
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-gray-900">{member.user_name}</p>
|
||||
<p class="text-sm text-gray-600">{member.user_email}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<Show
|
||||
when={props.canManageMembers && member.role !== 'owner'}
|
||||
fallback={<Badge variant={getRoleBadgeVariant(member.role)}>{member.role}</Badge>}
|
||||
>
|
||||
<Select
|
||||
value={member.role}
|
||||
onChange={(e) => props.onUpdateMemberRole(member.user_id, e.currentTarget.value, 'org')}
|
||||
disabled={props.membersPending}
|
||||
class="w-32"
|
||||
>
|
||||
<option value="member">Member</option>
|
||||
<option value="admin">Admin</option>
|
||||
<option value="owner">Owner</option>
|
||||
</Select>
|
||||
</Show>
|
||||
</td>
|
||||
<td class="text-sm text-gray-600">
|
||||
{new Date(member.created_at).toLocaleDateString()}
|
||||
</td>
|
||||
<td class="text-right">
|
||||
<Show when={props.canManageMembers && member.role !== 'owner'}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => props.onRemoveMember(member.user_id, 'org')}
|
||||
disabled={props.membersPending}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</Show>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{(member) => <MemberRow member={member} type="org" roles={orgRoles} />}
|
||||
</For>
|
||||
</tbody>
|
||||
</Table>
|
||||
</Show>
|
||||
</Card>
|
||||
</div>
|
||||
</Show>
|
||||
</table>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
|
||||
{/* Project Members Tab */}
|
||||
<Show when={activeTab() === 'project'}>
|
||||
<Card>
|
||||
<Show when={tab() === "project"}>
|
||||
<Show
|
||||
when={filteredProjectMembers().length > 0}
|
||||
fallback={
|
||||
<div class="p-12">
|
||||
<EmptyState
|
||||
icon={
|
||||
<svg class="w-16 h-16" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</svg>
|
||||
}
|
||||
title="No project members"
|
||||
description="Add members to this project to collaborate"
|
||||
/>
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-icon">
|
||||
<IconMembers class="w-5 h-5" />
|
||||
</div>
|
||||
<p class="empty-state-title">No project members</p>
|
||||
<p class="empty-state-description">
|
||||
Invite someone with a project role to grant scoped access.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Table>
|
||||
<div class="table-container">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Member</th>
|
||||
<th>Email</th>
|
||||
<th>Role</th>
|
||||
<th>Joined</th>
|
||||
<th style="width:1%" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={filteredProjectMembers()}>
|
||||
{(member) => (
|
||||
<MemberRow member={member} type="project" roles={projectRoles} />
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={pendingInvitations().length > 0}>
|
||||
<div class="card card-flush">
|
||||
<div class="card-header" style="padding:1.25rem 1.25rem 1rem;margin-bottom:0;border-bottom:1px solid var(--border)">
|
||||
<span class="card-header-title">Pending invitations</span>
|
||||
<span class="card-header-description">
|
||||
Invites that have been sent but not yet accepted.
|
||||
</span>
|
||||
</div>
|
||||
<div class="table-container">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Member</th>
|
||||
<th>Role</th>
|
||||
<th>Added</th>
|
||||
<th class="text-right">Actions</th>
|
||||
<th>Email</th>
|
||||
<th>Org role</th>
|
||||
<th>Project role</th>
|
||||
<th>Expires</th>
|
||||
<th style="width:1%" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={filteredProjectMembers()}>
|
||||
{(member) => (
|
||||
<For each={pendingInvitations()}>
|
||||
{(inv) => (
|
||||
<tr>
|
||||
<td>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-full bg-gradient-to-br from-green-500 to-teal-500 flex items-center justify-center text-white font-semibold">
|
||||
{member.user_name?.charAt(0).toUpperCase() ?? '?'}
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-gray-900">{member.user_name}</p>
|
||||
<p class="text-sm text-gray-600">{member.user_email}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2.5">
|
||||
<span class="avatar" style="width:1.75rem;height:1.75rem;font-size:0.625rem;background:var(--surface-3);color:var(--text-2);border-color:var(--border)">
|
||||
<IconMail class="w-3.5 h-3.5" />
|
||||
</span>
|
||||
<span class="font-medium text-text-1">{inv.email}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<Show
|
||||
when={props.canManageMembers}
|
||||
fallback={<Badge variant={getRoleBadgeVariant(member.role)}>{member.role}</Badge>}
|
||||
>
|
||||
<Select
|
||||
value={member.role}
|
||||
onChange={(e) => props.onUpdateMemberRole(member.user_id, e.currentTarget.value, 'project')}
|
||||
disabled={props.membersPending}
|
||||
class="w-32"
|
||||
>
|
||||
<option value="viewer">Viewer</option>
|
||||
<option value="developer">Developer</option>
|
||||
<option value="admin">Admin</option>
|
||||
</Select>
|
||||
<Badge variant={roleVariant(inv.org_role)}>{inv.org_role}</Badge>
|
||||
</td>
|
||||
<td>
|
||||
<Show when={inv.project_role} fallback={<span class="text-text-3">—</span>}>
|
||||
<Badge variant="neutral">
|
||||
{inv.project_role}
|
||||
<Show when={inv.project_name}> · {inv.project_name}</Show>
|
||||
</Badge>
|
||||
</Show>
|
||||
</td>
|
||||
<td class="text-sm text-gray-600">
|
||||
{new Date(member.created_at).toLocaleDateString()}
|
||||
</td>
|
||||
<td class="text-right">
|
||||
<td class="text-text-3">{formatDate(inv.expires_at)}</td>
|
||||
<td>
|
||||
<Show when={props.canManageMembers}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => props.onRemoveMember(member.user_id, 'project')}
|
||||
disabled={props.membersPending}
|
||||
<button
|
||||
class="btn btn-ghost btn-sm"
|
||||
onClick={() => props.onRevokeInvitation(inv.id)}
|
||||
disabled={props.invitationPending}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
Revoke
|
||||
</button>
|
||||
</Show>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</Table>
|
||||
</Show>
|
||||
</Card>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={props.memberMessage}>
|
||||
<Message variant="neutral">{props.memberMessage}</Message>
|
||||
</Show>
|
||||
|
||||
{/* Invite Modal */}
|
||||
<Modal open={showInviteModal()} onClose={() => setShowInviteModal(false)} title="Invite Team Member">
|
||||
<form class="space-y-4" onSubmit={handleInviteSubmit}>
|
||||
{/* Invite modal */}
|
||||
<Modal
|
||||
open={inviteOpen()}
|
||||
onClose={() => setInviteOpen(false)}
|
||||
title="Invite member"
|
||||
description="Send an email invitation with an organization role, optionally scoped to the active project."
|
||||
>
|
||||
<form onSubmit={handleInvite} class="space-y-4">
|
||||
<Input
|
||||
label="Email Address"
|
||||
label="Email address"
|
||||
type="email"
|
||||
placeholder="colleagu[email protected]"
|
||||
placeholder="teammat[email protected]"
|
||||
value={props.invitationInput.email}
|
||||
onInput={(e) => props.onInvitationInputChange('email', e.currentTarget.value)}
|
||||
disabled={props.invitationPending}
|
||||
onInput={(e) => props.onInvitationInputChange("email", e.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="Organization Role"
|
||||
label="Organization role"
|
||||
value={props.invitationInput.orgRole}
|
||||
onChange={(e) => props.onInvitationInputChange('orgRole', e.currentTarget.value)}
|
||||
disabled={props.invitationPending}
|
||||
onChange={(e) => props.onInvitationInputChange("orgRole", e.currentTarget.value)}
|
||||
>
|
||||
<option value="member">Member</option>
|
||||
<option value="admin">Admin</option>
|
||||
<For each={orgRoles}>
|
||||
{(role) => <option value={role}>{role}</option>}
|
||||
</For>
|
||||
</Select>
|
||||
|
||||
<Show when={props.hasActiveProject}>
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="flex items-center gap-2.5 text-sm text-text-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="attachProject"
|
||||
class="checkbox"
|
||||
checked={props.invitationInput.attachProject}
|
||||
onChange={(e) => props.onInvitationInputChange('attachProject', e.currentTarget.checked)}
|
||||
disabled={props.invitationPending}
|
||||
class="rounded border-gray-300"
|
||||
onChange={(e) =>
|
||||
props.onInvitationInputChange("attachProject", e.currentTarget.checked)
|
||||
}
|
||||
/>
|
||||
<label for="attachProject" class="text-sm text-gray-700">
|
||||
Add to current project
|
||||
</label>
|
||||
</div>
|
||||
|
||||
Also grant access to the active project
|
||||
</label>
|
||||
<Show when={props.invitationInput.attachProject}>
|
||||
<Select
|
||||
label="Project Role"
|
||||
label="Project role"
|
||||
value={props.invitationInput.projectRole}
|
||||
onChange={(e) => props.onInvitationInputChange('projectRole', e.currentTarget.value)}
|
||||
disabled={props.invitationPending}
|
||||
onChange={(e) =>
|
||||
props.onInvitationInputChange("projectRole", e.currentTarget.value)
|
||||
}
|
||||
>
|
||||
<option value="viewer">Viewer</option>
|
||||
<option value="developer">Developer</option>
|
||||
<option value="admin">Admin</option>
|
||||
<For each={projectRoles}>
|
||||
{(role) => <option value={role}>{role}</option>}
|
||||
</For>
|
||||
</Select>
|
||||
</Show>
|
||||
</Show>
|
||||
|
||||
<div class="flex gap-3 justify-end pt-4">
|
||||
<Button type="button" variant="ghost" onClick={() => setShowInviteModal(false)}>
|
||||
<ModalFooter>
|
||||
<button type="button" class="btn btn-ghost" onClick={() => setInviteOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" disabled={props.invitationPending}>
|
||||
{props.invitationPending ? "Sending..." : "Send Invitation"}
|
||||
</Button>
|
||||
</div>
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary" disabled={props.invitationPending}>
|
||||
<Show when={props.invitationPending} fallback="Send invitation">
|
||||
<span class="spinner" /> Sending…
|
||||
</Show>
|
||||
</button>
|
||||
</ModalFooter>
|
||||
</form>
|
||||
<Show when={props.inviteMessage}>
|
||||
<Message variant="neutral" class="mt-4">{props.inviteMessage}</Message>
|
||||
</Show>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,236 +1,294 @@
|
||||
import { Show, For, createSignal } from "solid-js";
|
||||
import { Button, Card, Input, Textarea, Badge, EmptyState, Message, Modal } from "../components";
|
||||
import { For, Show, createSignal } from "solid-js";
|
||||
import type { ProjectSummary } from "@primora/api-client";
|
||||
import { Badge } from "../components/Badge";
|
||||
import { Modal, ModalFooter } from "../components/Modal";
|
||||
import { Input, Textarea } from "../components/Input";
|
||||
import { IconPlus, IconProjects, IconArrowRight, IconEdit, IconTrash } from "../components/Icons";
|
||||
|
||||
interface ProjectInput {
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface ProjectsPageProps {
|
||||
projects: ProjectSummary[];
|
||||
selectedProjectID?: string;
|
||||
projectInput: { name: string; slug: string; description: string };
|
||||
projectEditInput: { name: string; slug: string; description: string };
|
||||
projectInput: ProjectInput;
|
||||
projectEditInput: ProjectInput;
|
||||
projectMessage: string;
|
||||
projectPending: boolean;
|
||||
canUpdateProject: boolean;
|
||||
onProjectInputChange: (field: string, value: string) => void;
|
||||
onProjectEditInputChange: (field: string, value: string) => void;
|
||||
onCreateProject: (e: SubmitEvent) => void;
|
||||
onUpdateProject: (e: SubmitEvent) => void;
|
||||
onProjectInputChange: (field: keyof ProjectInput, value: string) => void;
|
||||
onProjectEditInputChange: (field: keyof ProjectInput, value: string) => void;
|
||||
onCreateProject: (event: SubmitEvent) => void;
|
||||
onUpdateProject: (event: SubmitEvent) => void;
|
||||
onDeleteProject: () => void;
|
||||
onSelectProject: (id: string) => void;
|
||||
onNavigateToDashboard: () => void;
|
||||
}
|
||||
|
||||
export function ProjectsPage(props: ProjectsPageProps) {
|
||||
const [showCreateModal, setShowCreateModal] = createSignal(false);
|
||||
const [searchQuery, setSearchQuery] = createSignal("");
|
||||
const [createOpen, setCreateOpen] = createSignal(false);
|
||||
const [settingsOpen, setSettingsOpen] = createSignal(false);
|
||||
const [confirmDelete, setConfirmDelete] = createSignal(false);
|
||||
|
||||
const filteredProjects = () => {
|
||||
const query = searchQuery().toLowerCase();
|
||||
if (!query) return props.projects;
|
||||
return props.projects.filter(p =>
|
||||
p.name.toLowerCase().includes(query) ||
|
||||
p.slug.toLowerCase().includes(query) ||
|
||||
p.description?.toLowerCase().includes(query)
|
||||
);
|
||||
const activeProject = () =>
|
||||
props.projects.find((p) => p.id === props.selectedProjectID);
|
||||
|
||||
const roleVariant = (role?: string | null) =>
|
||||
role === "admin" ? "primary" : role === "developer" ? "success" : "neutral";
|
||||
|
||||
const handleCreate = async (e: SubmitEvent) => {
|
||||
props.onCreateProject(e);
|
||||
setCreateOpen(false);
|
||||
};
|
||||
|
||||
const handleCreateSubmit = (e: SubmitEvent) => {
|
||||
props.onCreateProject(e);
|
||||
setShowCreateModal(false);
|
||||
const handleUpdate = async (e: SubmitEvent) => {
|
||||
props.onUpdateProject(e);
|
||||
setSettingsOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="space-y-6">
|
||||
{/* Header */}
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900">Projects</h1>
|
||||
<p class="text-gray-600 mt-1">Manage your organization's projects</p>
|
||||
<h1 class="page-title">Projects</h1>
|
||||
<p class="page-description">
|
||||
Projects scope storage, collections, API keys, and members.
|
||||
</p>
|
||||
</div>
|
||||
<div class="page-actions">
|
||||
<button class="btn btn-primary" onClick={() => setCreateOpen(true)}>
|
||||
<IconPlus class="w-4 h-4" />
|
||||
New project
|
||||
</button>
|
||||
</div>
|
||||
<Button onClick={() => setShowCreateModal(true)}>
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
New Project
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Search Bar */}
|
||||
<Card class="p-4">
|
||||
<Input
|
||||
placeholder="Search projects by name, slug, or description..."
|
||||
value={searchQuery()}
|
||||
onInput={(e) => setSearchQuery(e.currentTarget.value)}
|
||||
class="w-full"
|
||||
/>
|
||||
</Card>
|
||||
<Show when={props.projectMessage}>
|
||||
<div class="message message-neutral">{props.projectMessage}</div>
|
||||
</Show>
|
||||
|
||||
{/* Projects Grid */}
|
||||
<Show
|
||||
when={filteredProjects().length > 0}
|
||||
fallback={
|
||||
<EmptyState
|
||||
icon={
|
||||
<svg class="w-16 h-16" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
}
|
||||
title="No projects found"
|
||||
description={searchQuery() ? "Try adjusting your search" : "Create your first project to get started"}
|
||||
action={
|
||||
<Show when={!searchQuery()}>
|
||||
<Button onClick={() => setShowCreateModal(true)}>Create Project</Button>
|
||||
</Show>
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<For each={filteredProjects()}>
|
||||
{(project) => (
|
||||
<Card
|
||||
class={`p-6 cursor-pointer transition-all hover:shadow-lg hover:border-blue-500 ${
|
||||
props.selectedProjectID === project.id ? 'border-blue-500 bg-blue-50' : ''
|
||||
}`}
|
||||
onClick={() => {
|
||||
props.onSelectProject(project.id);
|
||||
props.onNavigateToDashboard();
|
||||
}}
|
||||
>
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-1">{project.name}</h3>
|
||||
<Badge variant="secondary" class="text-xs">{project.slug}</Badge>
|
||||
</div>
|
||||
<Show when={project.membershipRole}>
|
||||
<Badge variant={project.membershipRole === 'admin' ? 'primary' : 'secondary'}>
|
||||
{project.membershipRole}
|
||||
</Badge>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={project.description}>
|
||||
<p class="text-sm text-gray-600 mb-4 line-clamp-2">{project.description}</p>
|
||||
</Show>
|
||||
|
||||
<div class="flex items-center justify-between pt-4 border-t border-gray-200">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
props.onSelectProject(project.id);
|
||||
props.onNavigateToDashboard();
|
||||
}}
|
||||
class="text-sm text-blue-600 hover:text-blue-700 font-medium"
|
||||
>
|
||||
View Dashboard →
|
||||
</button>
|
||||
<Show when={project.membershipRole === 'admin'}>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
<div class="card card-flush">
|
||||
<Show
|
||||
when={props.projects.length > 0}
|
||||
fallback={
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-icon">
|
||||
<IconProjects class="w-5 h-5" />
|
||||
</div>
|
||||
<p class="empty-state-title">No projects yet</p>
|
||||
<p class="empty-state-description">
|
||||
Create your first project to start storing files, collections and issuing API keys.
|
||||
</p>
|
||||
<button class="btn btn-primary mt-4" onClick={() => setCreateOpen(true)}>
|
||||
<IconPlus class="w-4 h-4" />
|
||||
Create project
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="table-container">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Slug</th>
|
||||
<th>Role</th>
|
||||
<th style="width: 1%">Status</th>
|
||||
<th style="width: 1%" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={props.projects}>
|
||||
{(project) => (
|
||||
<tr
|
||||
class="clickable"
|
||||
onClick={() => {
|
||||
props.onSelectProject(project.id);
|
||||
props.onNavigateToDashboard();
|
||||
}}
|
||||
class="text-sm text-gray-600 hover:text-gray-700"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</For>
|
||||
<td>
|
||||
<div class="flex items-center gap-2.5">
|
||||
<span
|
||||
class="ctx-icon"
|
||||
style="display:inline-flex;align-items:center;justify-content:center;width:1.5rem;height:1.5rem;border-radius:6px;background:var(--surface-3);font-size:0.625rem;font-weight:700;color:var(--text-2)"
|
||||
>
|
||||
{project.name[0]?.toUpperCase()}
|
||||
</span>
|
||||
<div>
|
||||
<div class="font-medium text-text-1">{project.name}</div>
|
||||
<Show when={project.description}>
|
||||
<div class="text-xs text-text-3">{project.description}</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<code>{project.slug}</code>
|
||||
</td>
|
||||
<td>
|
||||
<Badge variant={roleVariant(project.membershipRole)}>
|
||||
{project.membershipRole ?? "member"}
|
||||
</Badge>
|
||||
</td>
|
||||
<td>
|
||||
<Show when={project.id === props.selectedProjectID}>
|
||||
<Badge variant="primary">
|
||||
<span class="badge-dot" />
|
||||
Active
|
||||
</Badge>
|
||||
</Show>
|
||||
</td>
|
||||
<td>
|
||||
<IconArrowRight class="w-4 h-4 text-text-3" />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={activeProject()}>
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<span class="card-header-title">Project settings</span>
|
||||
<span class="card-header-description">
|
||||
Manage <strong>{activeProject()!.name}</strong>. Renaming changes the API-visible slug.
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
onClick={() => setSettingsOpen(true)}
|
||||
disabled={!props.canUpdateProject}
|
||||
>
|
||||
<IconEdit class="w-4 h-4" />
|
||||
Edit project
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-danger"
|
||||
onClick={() => setConfirmDelete(true)}
|
||||
disabled={!props.canUpdateProject}
|
||||
>
|
||||
<IconTrash class="w-4 h-4" />
|
||||
Delete project
|
||||
</button>
|
||||
</div>
|
||||
<Show when={!props.canUpdateProject}>
|
||||
<p class="label-hint mt-3">You need the admin role to modify this project.</p>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Selected Project Settings */}
|
||||
<Show when={props.selectedProjectID && props.canUpdateProject}>
|
||||
<Card class="p-6">
|
||||
<h2 class="text-xl font-semibold mb-4">Project Settings</h2>
|
||||
<form class="space-y-4" onSubmit={props.onUpdateProject}>
|
||||
<Input
|
||||
label="Project Name"
|
||||
placeholder="Enter project name"
|
||||
value={props.projectEditInput.name}
|
||||
onInput={(e) => props.onProjectEditInputChange('name', e.currentTarget.value)}
|
||||
disabled={props.projectPending}
|
||||
/>
|
||||
<Input
|
||||
label="Project Slug"
|
||||
placeholder="project-slug"
|
||||
value={props.projectEditInput.slug}
|
||||
onInput={(e) => props.onProjectEditInputChange('slug', e.currentTarget.value)}
|
||||
disabled={props.projectPending}
|
||||
/>
|
||||
<Textarea
|
||||
label="Description"
|
||||
placeholder="Describe your project..."
|
||||
value={props.projectEditInput.description}
|
||||
onInput={(e) => props.onProjectEditInputChange('description', e.currentTarget.value)}
|
||||
disabled={props.projectPending}
|
||||
rows={3}
|
||||
/>
|
||||
<div class="flex gap-3">
|
||||
<Button type="submit" variant="primary" disabled={props.projectPending}>
|
||||
{props.projectPending ? "Updating..." : "Update Project"}
|
||||
</Button>
|
||||
<Button type="button" variant="danger" onClick={props.onDeleteProject} disabled={props.projectPending}>
|
||||
Delete Project
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
<Show when={props.projectMessage}>
|
||||
<Message variant="neutral" class="mt-4">{props.projectMessage}</Message>
|
||||
</Show>
|
||||
</Card>
|
||||
</Show>
|
||||
|
||||
{/* Create Project Modal */}
|
||||
<Modal open={showCreateModal()} onClose={() => setShowCreateModal(false)} title="Create New Project">
|
||||
<form class="space-y-4" onSubmit={handleCreateSubmit}>
|
||||
{/* Create modal */}
|
||||
<Modal
|
||||
open={createOpen()}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
title="Create project"
|
||||
description="A project groups buckets, collections, API keys and members."
|
||||
>
|
||||
<form onSubmit={handleCreate} class="space-y-4">
|
||||
<Input
|
||||
label="Project Name"
|
||||
placeholder="My Awesome Project"
|
||||
label="Project name"
|
||||
placeholder="Production API"
|
||||
value={props.projectInput.name}
|
||||
onInput={(e) => {
|
||||
const name = e.currentTarget.value;
|
||||
props.onProjectInputChange('name', name);
|
||||
// Auto-generate slug
|
||||
const slug = name.toLowerCase().trim().replace(/[^a-z0-9\s-]/g, '').replace(/\s+/g, '-').replace(/-+/g, '-');
|
||||
props.onProjectInputChange('slug', slug);
|
||||
}}
|
||||
disabled={props.projectPending}
|
||||
onInput={(e) => props.onProjectInputChange("name", e.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Project Slug"
|
||||
placeholder="my-awesome-project"
|
||||
label="Slug"
|
||||
placeholder="production-api"
|
||||
value={props.projectInput.slug}
|
||||
onInput={(e) => props.onProjectInputChange('slug', e.currentTarget.value)}
|
||||
disabled={props.projectPending}
|
||||
onInput={(e) => props.onProjectInputChange("slug", e.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
<Textarea
|
||||
label="Description (Optional)"
|
||||
placeholder="What is this project about?"
|
||||
label="Description"
|
||||
placeholder="What does this project power?"
|
||||
value={props.projectInput.description}
|
||||
onInput={(e) => props.onProjectInputChange('description', e.currentTarget.value)}
|
||||
disabled={props.projectPending}
|
||||
rows={3}
|
||||
onInput={(e) => props.onProjectInputChange("description", e.currentTarget.value)}
|
||||
/>
|
||||
<div class="flex gap-3 justify-end pt-4">
|
||||
<Button type="button" variant="ghost" onClick={() => setShowCreateModal(false)}>
|
||||
<ModalFooter>
|
||||
<button type="button" class="btn btn-ghost" onClick={() => setCreateOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" disabled={props.projectPending}>
|
||||
{props.projectPending ? "Creating..." : "Create Project"}
|
||||
</Button>
|
||||
</div>
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary" disabled={props.projectPending}>
|
||||
<Show when={props.projectPending} fallback="Create project">
|
||||
<span class="spinner" /> Creating…
|
||||
</Show>
|
||||
</button>
|
||||
</ModalFooter>
|
||||
</form>
|
||||
<Show when={props.projectMessage}>
|
||||
<Message variant="neutral" class="mt-4">{props.projectMessage}</Message>
|
||||
</Show>
|
||||
</Modal>
|
||||
|
||||
{/* Edit modal */}
|
||||
<Modal
|
||||
open={settingsOpen()}
|
||||
onClose={() => setSettingsOpen(false)}
|
||||
title="Edit project"
|
||||
description="Update name, slug or description."
|
||||
>
|
||||
<form onSubmit={handleUpdate} class="space-y-4">
|
||||
<Input
|
||||
label="Project name"
|
||||
value={props.projectEditInput.name}
|
||||
onInput={(e) => props.onProjectEditInputChange("name", e.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Slug"
|
||||
value={props.projectEditInput.slug}
|
||||
onInput={(e) => props.onProjectEditInputChange("slug", e.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
<Textarea
|
||||
label="Description"
|
||||
value={props.projectEditInput.description}
|
||||
onInput={(e) => props.onProjectEditInputChange("description", e.currentTarget.value)}
|
||||
/>
|
||||
<ModalFooter>
|
||||
<button type="button" class="btn btn-ghost" onClick={() => setSettingsOpen(false)}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary" disabled={props.projectPending}>
|
||||
Save changes
|
||||
</button>
|
||||
</ModalFooter>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
{/* Delete confirmation */}
|
||||
<Modal
|
||||
open={confirmDelete()}
|
||||
onClose={() => setConfirmDelete(false)}
|
||||
title="Delete project"
|
||||
size="sm"
|
||||
>
|
||||
<p class="text-sm text-text-2">
|
||||
Deleting <strong class="text-text-1">{activeProject()?.name}</strong> permanently removes
|
||||
its buckets, objects, collections, API keys, members and audit history. This cannot be undone.
|
||||
</p>
|
||||
<ModalFooter>
|
||||
<button class="btn btn-ghost" onClick={() => setConfirmDelete(false)}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-danger"
|
||||
onClick={() => {
|
||||
setConfirmDelete(false);
|
||||
props.onDeleteProject();
|
||||
}}
|
||||
disabled={props.projectPending}
|
||||
>
|
||||
Delete permanently
|
||||
</button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,21 @@
|
||||
import { Show, For, createSignal } from "solid-js";
|
||||
import { Button, Card, Input, Badge, Table, EmptyState, Message, Modal, Tabs, TabPanel } from "../components";
|
||||
import { For, Show, createSignal } from "solid-js";
|
||||
import type { ApiKey } from "@primora/api-client";
|
||||
import { Badge } from "../components/Badge";
|
||||
import { Modal, ModalFooter } from "../components/Modal";
|
||||
import { Input } from "../components/Input";
|
||||
import {
|
||||
IconPlus,
|
||||
IconKey,
|
||||
IconCopy,
|
||||
IconCheck,
|
||||
IconTrash,
|
||||
IconAlert,
|
||||
} from "../components/Icons";
|
||||
|
||||
interface OrgInput {
|
||||
name: string;
|
||||
slug: string;
|
||||
}
|
||||
|
||||
interface SettingsPageProps {
|
||||
apiKeys?: ApiKey[];
|
||||
@@ -8,356 +23,352 @@ interface SettingsPageProps {
|
||||
apiKeySecret: string;
|
||||
apiKeyMessage: string;
|
||||
apiKeyPending: boolean;
|
||||
organizationInput: { name: string; slug: string };
|
||||
organizationEditInput: { name: string; slug: string };
|
||||
organizationInput: OrgInput;
|
||||
organizationEditInput: OrgInput;
|
||||
organizationMessage: string;
|
||||
canUpdateOrganization: boolean;
|
||||
workspacePending: boolean;
|
||||
hasActiveOrganization: boolean;
|
||||
onApiKeyNameChange: (name: string) => void;
|
||||
onCreateApiKey: (e: SubmitEvent) => void;
|
||||
onCreateApiKey: () => void;
|
||||
onDeleteApiKey: (id: string) => void;
|
||||
onOrganizationInputChange: (field: string, value: string) => void;
|
||||
onOrganizationEditInputChange: (field: string, value: string) => void;
|
||||
onCreateOrganization: (e: SubmitEvent) => void;
|
||||
onUpdateOrganization: (e: SubmitEvent) => void;
|
||||
onOrganizationInputChange: (field: keyof OrgInput, value: string) => void;
|
||||
onOrganizationEditInputChange: (field: keyof OrgInput, value: string) => void;
|
||||
onCreateOrganization: (event: SubmitEvent) => void;
|
||||
onUpdateOrganization: (event: SubmitEvent) => void;
|
||||
onDeleteOrganization: () => void;
|
||||
formatDate: (date?: string | null) => string;
|
||||
onDismissSecret: () => void;
|
||||
formatDate: (value?: string | null) => string;
|
||||
}
|
||||
|
||||
export function SettingsPage(props: SettingsPageProps) {
|
||||
const [activeTab, setActiveTab] = createSignal<'api-keys' | 'organization' | 'general'>('api-keys');
|
||||
const [showCreateKeyModal, setShowCreateKeyModal] = createSignal(false);
|
||||
const [showKeySecret, setShowKeySecret] = createSignal(false);
|
||||
const [copiedKey, setCopiedKey] = createSignal(false);
|
||||
const [tab, setTab] = createSignal<"api-keys" | "organization">("api-keys");
|
||||
const [copied, setCopied] = createSignal(false);
|
||||
const [createOrgOpen, setCreateOrgOpen] = createSignal(false);
|
||||
const [confirmDeleteOrg, setConfirmDeleteOrg] = createSignal(false);
|
||||
|
||||
const handleCreateKey = (e: SubmitEvent) => {
|
||||
props.onCreateApiKey(e);
|
||||
setShowCreateKeyModal(false);
|
||||
setShowKeySecret(true);
|
||||
const copySecret = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(props.apiKeySecret);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
/* clipboard unavailable */
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = (text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
setCopiedKey(true);
|
||||
setTimeout(() => setCopiedKey(false), 2000);
|
||||
};
|
||||
const activeKeys = () => (props.apiKeys ?? []).filter((k) => !k.revoked_at);
|
||||
const revokedKeys = () => (props.apiKeys ?? []).filter((k) => k.revoked_at);
|
||||
|
||||
return (
|
||||
<div class="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900">Settings</h1>
|
||||
<p class="text-gray-600 mt-1">Manage API keys, organization, and project settings</p>
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">Settings</h1>
|
||||
<p class="page-description">
|
||||
API credentials and organization management.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs
|
||||
tabs={[
|
||||
{ id: 'api-keys', label: 'API Keys' },
|
||||
{ id: 'organization', label: 'Organization' },
|
||||
{ id: 'general', label: 'General' },
|
||||
]}
|
||||
activeTab={activeTab()}
|
||||
onChange={(id) => setActiveTab(id as any)}
|
||||
/>
|
||||
<div class="tabs">
|
||||
<button
|
||||
class={`tab ${tab() === "api-keys" ? "active" : ""}`}
|
||||
onClick={() => setTab("api-keys")}
|
||||
>
|
||||
API keys
|
||||
<span class="nav-badge ml-1.5">{activeKeys().length}</span>
|
||||
</button>
|
||||
<button
|
||||
class={`tab ${tab() === "organization" ? "active" : ""}`}
|
||||
onClick={() => setTab("organization")}
|
||||
>
|
||||
Organization
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* API Keys Tab */}
|
||||
<Show when={activeTab() === 'api-keys'}>
|
||||
<div class="space-y-6">
|
||||
<Card class="p-6">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold">API Keys</h2>
|
||||
<p class="text-sm text-gray-600 mt-1">Manage authentication keys for your project</p>
|
||||
</div>
|
||||
<Button onClick={() => setShowCreateKeyModal(true)}>
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Create API Key
|
||||
</Button>
|
||||
{/* ---------------- API keys ---------------- */}
|
||||
<Show when={tab() === "api-keys"}>
|
||||
<Show when={props.apiKeyMessage}>
|
||||
<div class="message message-neutral">{props.apiKeyMessage}</div>
|
||||
</Show>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<span class="card-header-title">Create API key</span>
|
||||
<span class="card-header-description">
|
||||
Keys authenticate requests with the <code>X-API-Key</code> header. The secret is shown once.
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-end gap-2 flex-wrap">
|
||||
<div style="flex:1;min-width:14rem;max-width:24rem">
|
||||
<Input
|
||||
label="Key name"
|
||||
placeholder="Frontend key"
|
||||
value={props.apiKeyName}
|
||||
onInput={(e) => props.onApiKeyNameChange(e.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Show
|
||||
when={(props.apiKeys?.length ?? 0) > 0}
|
||||
fallback={
|
||||
<EmptyState
|
||||
icon={
|
||||
<svg class="w-16 h-16" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||
</svg>
|
||||
}
|
||||
title="No API keys"
|
||||
description="Create an API key to authenticate your applications"
|
||||
action={
|
||||
<Button onClick={() => setShowCreateKeyModal(true)}>Create API Key</Button>
|
||||
}
|
||||
/>
|
||||
}
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
onClick={props.onCreateApiKey}
|
||||
disabled={props.apiKeyPending || !props.apiKeyName.trim()}
|
||||
>
|
||||
<Table>
|
||||
<IconPlus class="w-4 h-4" />
|
||||
Create key
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card card-flush">
|
||||
<Show
|
||||
when={(props.apiKeys ?? []).length > 0}
|
||||
fallback={
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-icon">
|
||||
<IconKey class="w-5 h-5" />
|
||||
</div>
|
||||
<p class="empty-state-title">No API keys</p>
|
||||
<p class="empty-state-description">
|
||||
Create a key to authenticate API requests from your application.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="table-container">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Key ID</th>
|
||||
<th>Prefix</th>
|
||||
<th>Last used</th>
|
||||
<th>Status</th>
|
||||
<th>Created</th>
|
||||
<th>Last Used</th>
|
||||
<th class="text-right">Actions</th>
|
||||
<th style="width:1%" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={props.apiKeys}>
|
||||
<For each={props.apiKeys ?? []}>
|
||||
{(key) => (
|
||||
<tr>
|
||||
<td class="font-medium text-gray-900">{key.name}</td>
|
||||
<td class="font-medium text-text-1">{key.name}</td>
|
||||
<td>
|
||||
<code class="text-xs bg-gray-100 px-2 py-1 rounded">
|
||||
{key.key_id}
|
||||
</code>
|
||||
<code>{key.prefix}…</code>
|
||||
</td>
|
||||
<td class="text-text-3 text-xs">
|
||||
{key.last_used_at ? props.formatDate(key.last_used_at) : "Never"}
|
||||
</td>
|
||||
<td>
|
||||
<Badge variant={key.is_active ? 'success' : 'secondary'}>
|
||||
{key.is_active ? 'Active' : 'Inactive'}
|
||||
<Badge variant={key.revoked_at ? "neutral" : "success"}>
|
||||
<span class="badge-dot" />
|
||||
{key.revoked_at ? "Revoked" : "Active"}
|
||||
</Badge>
|
||||
</td>
|
||||
<td class="text-sm text-gray-600">{props.formatDate(key.created_at)}</td>
|
||||
<td class="text-sm text-gray-600">
|
||||
{key.last_used_at ? props.formatDate(key.last_used_at) : 'Never'}
|
||||
</td>
|
||||
<td class="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => props.onDeleteApiKey(key.id)}
|
||||
disabled={props.apiKeyPending}
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</Button>
|
||||
<td>
|
||||
<Show when={!key.revoked_at}>
|
||||
<button
|
||||
class="icon-btn"
|
||||
title="Revoke key"
|
||||
aria-label={`Revoke ${key.name}`}
|
||||
onClick={() => props.onDeleteApiKey(key.id)}
|
||||
disabled={props.apiKeyPending}
|
||||
>
|
||||
<IconTrash class="w-4 h-4" />
|
||||
</button>
|
||||
</Show>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</Table>
|
||||
</Show>
|
||||
</Card>
|
||||
|
||||
<Card class="p-6 bg-blue-50 border-blue-200">
|
||||
<div class="flex gap-4">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="w-6 h-6 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold text-blue-900 mb-1">Keep your API keys secure</h3>
|
||||
<p class="text-sm text-blue-800">
|
||||
API keys provide full access to your project. Never share them publicly or commit them to version control.
|
||||
Store them securely using environment variables or secret management services.
|
||||
</p>
|
||||
</div>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Show when={props.apiKeyMessage}>
|
||||
<Message variant="neutral">{props.apiKeyMessage}</Message>
|
||||
<Show when={revokedKeys().length > 0}>
|
||||
<div class="px-4 py-2.5 text-xs text-text-3" style="border-top:1px solid var(--border)">
|
||||
{revokedKeys().length} revoked key{revokedKeys().length === 1 ? "" : "s"} retained for audit.
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Organization Tab */}
|
||||
<Show when={activeTab() === 'organization'}>
|
||||
<div class="space-y-6">
|
||||
<Card class="p-6">
|
||||
<h2 class="text-xl font-semibold mb-4">Organization Settings</h2>
|
||||
<form class="space-y-4" onSubmit={props.onUpdateOrganization}>
|
||||
<Input
|
||||
label="Organization Name"
|
||||
placeholder="My Organization"
|
||||
value={props.organizationEditInput.name}
|
||||
onInput={(e) => props.onOrganizationEditInputChange('name', e.currentTarget.value)}
|
||||
disabled={props.workspacePending || !props.canUpdateOrganization}
|
||||
/>
|
||||
<Input
|
||||
label="Organization Slug"
|
||||
placeholder="my-organization"
|
||||
value={props.organizationEditInput.slug}
|
||||
onInput={(e) => props.onOrganizationEditInputChange('slug', e.currentTarget.value)}
|
||||
disabled={props.workspacePending || !props.canUpdateOrganization}
|
||||
/>
|
||||
<Show when={props.canUpdateOrganization}>
|
||||
<div class="flex gap-3">
|
||||
<Button type="submit" variant="primary" disabled={props.workspacePending}>
|
||||
{props.workspacePending ? "Updating..." : "Update Organization"}
|
||||
</Button>
|
||||
<Button type="button" variant="danger" onClick={props.onDeleteOrganization} disabled={props.workspacePending}>
|
||||
Delete Organization
|
||||
</Button>
|
||||
</div>
|
||||
{/* ---------------- Organization ---------------- */}
|
||||
<Show when={tab() === "organization"}>
|
||||
<Show when={props.organizationMessage}>
|
||||
<div class="message message-neutral">{props.organizationMessage}</div>
|
||||
</Show>
|
||||
|
||||
<Show when={props.hasActiveOrganization}>
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<span class="card-header-title">Organization settings</span>
|
||||
<span class="card-header-description">
|
||||
Rename the active organization or change its slug.
|
||||
</span>
|
||||
</div>
|
||||
<form onSubmit={props.onUpdateOrganization} class="space-y-4">
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<Input
|
||||
label="Name"
|
||||
value={props.organizationEditInput.name}
|
||||
onInput={(e) =>
|
||||
props.onOrganizationEditInputChange("name", e.currentTarget.value)
|
||||
}
|
||||
disabled={!props.canUpdateOrganization}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Slug"
|
||||
value={props.organizationEditInput.slug}
|
||||
onInput={(e) =>
|
||||
props.onOrganizationEditInputChange("slug", e.currentTarget.value)
|
||||
}
|
||||
disabled={!props.canUpdateOrganization}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary"
|
||||
disabled={!props.canUpdateOrganization || props.workspacePending}
|
||||
>
|
||||
Save changes
|
||||
</button>
|
||||
</div>
|
||||
<Show when={!props.canUpdateOrganization}>
|
||||
<p class="label-hint">You need the owner or admin role to edit this organization.</p>
|
||||
</Show>
|
||||
</form>
|
||||
<Show when={props.organizationMessage}>
|
||||
<Message variant="neutral" class="mt-4">{props.organizationMessage}</Message>
|
||||
</Show>
|
||||
</Card>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Card class="p-6 bg-red-50 border-red-200">
|
||||
<div class="flex gap-4">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="w-6 h-6 text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold text-red-900 mb-1">Danger Zone</h3>
|
||||
<p class="text-sm text-red-800">
|
||||
Deleting an organization is permanent and cannot be undone. All projects, members, API keys, buckets, and data will be permanently deleted.
|
||||
<div class="card">
|
||||
<div class="flex items-center justify-between gap-3 flex-wrap">
|
||||
<div>
|
||||
<span class="card-header-title">New organization</span>
|
||||
<p class="card-header-description mt-1">
|
||||
Organizations are top-level workspaces containing projects and members.
|
||||
</p>
|
||||
</div>
|
||||
<button class="btn btn-secondary" onClick={() => setCreateOrgOpen(true)}>
|
||||
<IconPlus class="w-4 h-4" />
|
||||
Create organization
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={props.hasActiveOrganization && props.canUpdateOrganization}>
|
||||
<div class="card" style="border-color:rgba(248,113,113,0.35)">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="icon-btn" style="color:var(--error);cursor:default">
|
||||
<IconAlert class="w-5 h-5" />
|
||||
</span>
|
||||
<div class="flex-1">
|
||||
<span class="card-header-title" style="color:var(--error)">Danger zone</span>
|
||||
<p class="card-header-description mt-1">
|
||||
Deleting the organization permanently removes all projects, buckets, objects,
|
||||
members, invitations, API keys and audit history.
|
||||
</p>
|
||||
<button
|
||||
class="btn btn-danger mt-3"
|
||||
onClick={() => setConfirmDeleteOrg(true)}
|
||||
disabled={props.workspacePending}
|
||||
>
|
||||
<IconTrash class="w-4 h-4" />
|
||||
Delete organization
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
|
||||
{/* General Tab */}
|
||||
<Show when={activeTab() === 'general'}>
|
||||
<div class="space-y-6">
|
||||
<Card class="p-6">
|
||||
<h2 class="text-xl font-semibold mb-4">General Settings</h2>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Theme</label>
|
||||
<select class="input w-full max-w-xs">
|
||||
<option>Light</option>
|
||||
<option>Dark</option>
|
||||
<option>System</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Language</label>
|
||||
<select class="input w-full max-w-xs">
|
||||
<option>English</option>
|
||||
<option>Spanish</option>
|
||||
<option>French</option>
|
||||
<option>German</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Timezone</label>
|
||||
<select class="input w-full max-w-xs">
|
||||
<option>UTC</option>
|
||||
<option>America/New_York</option>
|
||||
<option>America/Los_Angeles</option>
|
||||
<option>Europe/London</option>
|
||||
<option>Asia/Tokyo</option>
|
||||
</select>
|
||||
</div>
|
||||
{/* API key secret modal — shown while App holds a fresh secret */}
|
||||
<Show when={props.apiKeySecret}>
|
||||
<Modal
|
||||
open={true}
|
||||
onClose={props.onDismissSecret}
|
||||
title="API key created"
|
||||
description="Store this secret securely — it will not be shown again."
|
||||
size="sm"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<div class="code-block break-all select-all" style="user-select:all">
|
||||
{props.apiKeySecret}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card class="p-6">
|
||||
<h2 class="text-xl font-semibold mb-4">Notifications</h2>
|
||||
<div class="space-y-3">
|
||||
<label class="flex items-center gap-3">
|
||||
<input type="checkbox" class="rounded border-gray-300" checked />
|
||||
<div>
|
||||
<p class="font-medium text-gray-900">Email notifications</p>
|
||||
<p class="text-sm text-gray-600">Receive email updates about your projects</p>
|
||||
</div>
|
||||
</label>
|
||||
<label class="flex items-center gap-3">
|
||||
<input type="checkbox" class="rounded border-gray-300" checked />
|
||||
<div>
|
||||
<p class="font-medium text-gray-900">Security alerts</p>
|
||||
<p class="text-sm text-gray-600">Get notified about security events</p>
|
||||
</div>
|
||||
</label>
|
||||
<label class="flex items-center gap-3">
|
||||
<input type="checkbox" class="rounded border-gray-300" />
|
||||
<div>
|
||||
<p class="font-medium text-gray-900">Product updates</p>
|
||||
<p class="text-sm text-gray-600">Stay informed about new features</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
<button class="btn btn-secondary w-full" onClick={copySecret}>
|
||||
<Show when={copied()} fallback={<IconCopy class="w-4 h-4" />}>
|
||||
<IconCheck class="w-4 h-4" />
|
||||
</Show>
|
||||
{copied() ? "Copied" : "Copy secret"}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
</Show>
|
||||
|
||||
{/* Create API Key Modal */}
|
||||
<Modal open={showCreateKeyModal()} onClose={() => setShowCreateKeyModal(false)} title="Create API Key">
|
||||
<form class="space-y-4" onSubmit={handleCreateKey}>
|
||||
{/* create organization modal */}
|
||||
<Modal
|
||||
open={createOrgOpen()}
|
||||
onClose={() => setCreateOrgOpen(false)}
|
||||
title="Create organization"
|
||||
>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
props.onCreateOrganization(e);
|
||||
setCreateOrgOpen(false);
|
||||
}}
|
||||
class="space-y-4"
|
||||
>
|
||||
<Input
|
||||
label="Key Name"
|
||||
placeholder="Production API Key"
|
||||
value={props.apiKeyName}
|
||||
onInput={(e) => props.onApiKeyNameChange(e.currentTarget.value)}
|
||||
disabled={props.apiKeyPending}
|
||||
label="Organization name"
|
||||
placeholder="Acme Corporation"
|
||||
value={props.organizationInput.name}
|
||||
onInput={(e) => props.onOrganizationInputChange("name", e.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
||||
<p class="text-sm text-yellow-800">
|
||||
<strong>Important:</strong> The API key secret will only be shown once. Make sure to copy and store it securely.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-3 justify-end pt-4">
|
||||
<Button type="button" variant="ghost" onClick={() => setShowCreateKeyModal(false)}>
|
||||
<Input
|
||||
label="Slug"
|
||||
placeholder="acme-corp"
|
||||
value={props.organizationInput.slug}
|
||||
onInput={(e) => props.onOrganizationInputChange("slug", e.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
<ModalFooter>
|
||||
<button type="button" class="btn btn-ghost" onClick={() => setCreateOrgOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" disabled={props.apiKeyPending}>
|
||||
{props.apiKeyPending ? "Creating..." : "Create Key"}
|
||||
</Button>
|
||||
</div>
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary" disabled={props.workspacePending}>
|
||||
Create organization
|
||||
</button>
|
||||
</ModalFooter>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
{/* API Key Secret Modal */}
|
||||
<Modal open={showKeySecret()} onClose={() => setShowKeySecret(false)} title="API Key Created">
|
||||
<div class="space-y-4">
|
||||
<div class="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<p class="text-sm text-green-800 mb-2">
|
||||
<strong>Success!</strong> Your API key has been created.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">API Key Secret</label>
|
||||
<div class="flex gap-2">
|
||||
<code class="flex-1 p-3 bg-gray-900 text-gray-100 rounded-lg text-sm font-mono break-all">
|
||||
{props.apiKeySecret}
|
||||
</code>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => copyToClipboard(props.apiKeySecret)}
|
||||
>
|
||||
{copiedKey() ? (
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<p class="text-sm text-red-800">
|
||||
<strong>Warning:</strong> This is the only time you'll see this secret. Copy it now and store it securely.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end pt-4">
|
||||
<Button onClick={() => setShowKeySecret(false)}>
|
||||
I've Saved the Key
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/* delete org confirm */}
|
||||
<Modal
|
||||
open={confirmDeleteOrg()}
|
||||
onClose={() => setConfirmDeleteOrg(false)}
|
||||
title="Delete organization"
|
||||
size="sm"
|
||||
>
|
||||
<p class="text-sm text-text-2">
|
||||
This permanently deletes the organization and everything inside it. This cannot be undone.
|
||||
</p>
|
||||
<ModalFooter>
|
||||
<button class="btn btn-ghost" onClick={() => setConfirmDeleteOrg(false)}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-danger"
|
||||
onClick={() => {
|
||||
setConfirmDeleteOrg(false);
|
||||
props.onDeleteOrganization();
|
||||
}}
|
||||
disabled={props.workspacePending}
|
||||
>
|
||||
Delete permanently
|
||||
</button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,440 +1,547 @@
|
||||
import { Show, For, createSignal } from "solid-js";
|
||||
import { Button, Card, Input, Select, Badge, Table, EmptyState, Message, Modal, FileInput } from "../components";
|
||||
import type { Bucket, BucketObject } from "@primora/api-client";
|
||||
import { For, Show, createMemo, createSignal } from "solid-js";
|
||||
import type {
|
||||
Bucket,
|
||||
BucketObject,
|
||||
BucketObjectListResponse,
|
||||
} from "@primora/api-client";
|
||||
import { Badge } from "../components/Badge";
|
||||
import { Modal, ModalFooter } from "../components/Modal";
|
||||
import { Input, Select } from "../components/Input";
|
||||
import {
|
||||
IconPlus,
|
||||
IconStorage,
|
||||
IconFile,
|
||||
IconUpload,
|
||||
IconDownload,
|
||||
IconTrash,
|
||||
IconCopy,
|
||||
IconEdit,
|
||||
IconChevronRight,
|
||||
IconEye,
|
||||
IconLink,
|
||||
} from "../components/Icons";
|
||||
|
||||
type ObjectPreview =
|
||||
| { kind: "image"; objectURL: string }
|
||||
| { kind: "text"; text: string; truncated: boolean }
|
||||
| { kind: "unsupported"; message: string };
|
||||
|
||||
interface BucketInput {
|
||||
name: string;
|
||||
slug: string;
|
||||
visibility: string;
|
||||
}
|
||||
|
||||
interface StoragePageProps {
|
||||
buckets?: Bucket[];
|
||||
objects?: BucketObject[];
|
||||
objects: BucketObject[];
|
||||
selectedBucketID?: string;
|
||||
selectedObjectKey?: string;
|
||||
bucketInput: { name: string; slug: string; visibility: string };
|
||||
bucketEditInput: { name: string; slug: string; visibility: string };
|
||||
bucketInput: BucketInput;
|
||||
bucketEditInput: BucketInput;
|
||||
storageMessage: string;
|
||||
storagePending: boolean;
|
||||
canUpdateBucket: boolean;
|
||||
objectsPage?: { items: BucketObject[]; total: number; limit: number; offset: number };
|
||||
objectPreview?: any;
|
||||
onBucketInputChange: (field: string, value: string) => void;
|
||||
onBucketEditInputChange: (field: string, value: string) => void;
|
||||
onCreateBucket: (e: SubmitEvent) => void;
|
||||
onUpdateBucket: (e: SubmitEvent) => void;
|
||||
objectsPage?: BucketObjectListResponse;
|
||||
objectPreview?: ObjectPreview;
|
||||
previewLoading?: boolean;
|
||||
renameObjectKey: string;
|
||||
moveDestinationBucketID: string;
|
||||
onBucketInputChange: (field: keyof BucketInput, value: string) => void;
|
||||
onBucketEditInputChange: (field: keyof BucketInput, value: string) => void;
|
||||
onCreateBucket: () => void;
|
||||
onUpdateBucket: () => void;
|
||||
onDeleteBucket: () => void;
|
||||
onSelectBucket: (id: string) => void;
|
||||
onSelectObject: (key: string) => void;
|
||||
onUploadObject: (file: File) => void;
|
||||
onDeleteObject: (key: string) => void;
|
||||
onDownloadObject: (bucketId: string, key: string) => void;
|
||||
onCopyObjectURL: (object: BucketObject) => void;
|
||||
onMoveObject: () => void;
|
||||
onCopyObject: () => void;
|
||||
onRenameObjectKeyChange: (value: string) => void;
|
||||
onMoveDestinationChange: (value: string) => void;
|
||||
onObjectPageChange: (offset: number) => void;
|
||||
formatBytes: (bytes: number) => string;
|
||||
formatDate: (date?: string | null) => string;
|
||||
formatDate: (value?: string | null) => string;
|
||||
}
|
||||
|
||||
const OBJECTS_PAGE_SIZE = 25;
|
||||
|
||||
export function StoragePage(props: StoragePageProps) {
|
||||
const [showCreateBucketModal, setShowCreateBucketModal] = createSignal(false);
|
||||
const [showUploadModal, setShowUploadModal] = createSignal(false);
|
||||
const [showObjectPreview, setShowObjectPreview] = createSignal(false);
|
||||
const [searchQuery, setSearchQuery] = createSignal("");
|
||||
const [selectedFile, setSelectedFile] = createSignal<File | undefined>();
|
||||
const [createOpen, setCreateOpen] = createSignal(false);
|
||||
const [bucketSettingsOpen, setBucketSettingsOpen] = createSignal(false);
|
||||
const [objectSearch, setObjectSearch] = createSignal("");
|
||||
const [dragging, setDragging] = createSignal(false);
|
||||
|
||||
const filteredBuckets = () => {
|
||||
const query = searchQuery().toLowerCase();
|
||||
if (!query || !props.buckets) return props.buckets || [];
|
||||
return props.buckets.filter(b =>
|
||||
b.name.toLowerCase().includes(query) ||
|
||||
b.slug.toLowerCase().includes(query)
|
||||
);
|
||||
};
|
||||
const activeBucket = createMemo(() =>
|
||||
(props.buckets ?? []).find((b) => b.id === props.selectedBucketID),
|
||||
);
|
||||
|
||||
const handleCreateBucket = (e: SubmitEvent) => {
|
||||
props.onCreateBucket(e);
|
||||
setShowCreateBucketModal(false);
|
||||
};
|
||||
const selectedObject = createMemo(() =>
|
||||
props.objects.find((o) => o.object_key === props.selectedObjectKey),
|
||||
);
|
||||
|
||||
const handleUpload = () => {
|
||||
const file = selectedFile();
|
||||
if (file) {
|
||||
props.onUploadObject(file);
|
||||
setSelectedFile(undefined);
|
||||
setShowUploadModal(false);
|
||||
}
|
||||
};
|
||||
const filteredObjects = createMemo(() => {
|
||||
const q = objectSearch().toLowerCase();
|
||||
if (!q) return props.objects;
|
||||
return props.objects.filter((o) => o.object_key.toLowerCase().includes(q));
|
||||
});
|
||||
|
||||
const getVisibilityBadge = (visibility: string) => {
|
||||
return visibility === 'public' ?
|
||||
<Badge variant="success">Public</Badge> :
|
||||
<Badge variant="secondary">Private</Badge>;
|
||||
const pageMeta = () => props.objectsPage;
|
||||
const currentPage = () => Math.floor((pageMeta()?.offset ?? 0) / OBJECTS_PAGE_SIZE) + 1;
|
||||
const totalPages = () =>
|
||||
Math.max(1, Math.ceil((pageMeta()?.total ?? props.objects.length) / OBJECTS_PAGE_SIZE));
|
||||
|
||||
const handleFiles = (files: FileList | null) => {
|
||||
const file = files?.[0];
|
||||
if (file) props.onUploadObject(file);
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="space-y-6">
|
||||
{/* Header */}
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900">Storage</h1>
|
||||
<p class="text-gray-600 mt-1">Manage buckets and objects</p>
|
||||
<h1 class="page-title">Storage</h1>
|
||||
<p class="page-description">
|
||||
Buckets hold files on your own filesystem — no external object store required.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<Show when={props.selectedBucketID}>
|
||||
<Button variant="secondary" onClick={() => setShowUploadModal(true)}>
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||
</svg>
|
||||
Upload File
|
||||
</Button>
|
||||
</Show>
|
||||
<Button onClick={() => setShowCreateBucketModal(true)}>
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
New Bucket
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Buckets List */}
|
||||
<div class="lg:col-span-1 space-y-4">
|
||||
<Card class="p-4">
|
||||
<Input
|
||||
placeholder="Search buckets..."
|
||||
value={searchQuery()}
|
||||
onInput={(e) => setSearchQuery(e.currentTarget.value)}
|
||||
class="w-full"
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card class="p-4">
|
||||
<h2 class="text-lg font-semibold mb-4">Buckets</h2>
|
||||
<Show
|
||||
when={filteredBuckets().length > 0}
|
||||
fallback={
|
||||
<EmptyState
|
||||
icon={
|
||||
<svg class="w-12 h-12" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 3h4m-4 4h4" />
|
||||
</svg>
|
||||
}
|
||||
title="No buckets"
|
||||
description="Create a bucket to store files"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div class="space-y-2">
|
||||
<For each={filteredBuckets()}>
|
||||
{(bucket) => (
|
||||
<button
|
||||
onClick={() => props.onSelectBucket(bucket.id)}
|
||||
class={`w-full text-left p-3 rounded-lg border transition-all ${
|
||||
props.selectedBucketID === bucket.id
|
||||
? 'border-blue-500 bg-blue-50'
|
||||
: 'border-gray-200 hover:border-gray-300 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="font-medium text-gray-900">{bucket.name}</span>
|
||||
{getVisibilityBadge(bucket.visibility)}
|
||||
</div>
|
||||
<p class="text-xs text-gray-600">{bucket.slug}</p>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
{bucket.object_count} objects • {props.formatBytes(bucket.size_bytes)}
|
||||
</p>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</Card>
|
||||
|
||||
{/* Bucket Settings */}
|
||||
<Show when={props.selectedBucketID && props.canUpdateBucket}>
|
||||
<Card class="p-4">
|
||||
<h2 class="text-lg font-semibold mb-4">Bucket Settings</h2>
|
||||
<form class="space-y-3" onSubmit={props.onUpdateBucket}>
|
||||
<Input
|
||||
label="Name"
|
||||
value={props.bucketEditInput.name}
|
||||
onInput={(e) => props.onBucketEditInputChange('name', e.currentTarget.value)}
|
||||
disabled={props.storagePending}
|
||||
/>
|
||||
<Input
|
||||
label="Slug"
|
||||
value={props.bucketEditInput.slug}
|
||||
onInput={(e) => props.onBucketEditInputChange('slug', e.currentTarget.value)}
|
||||
disabled={props.storagePending}
|
||||
/>
|
||||
<Select
|
||||
label="Visibility"
|
||||
value={props.bucketEditInput.visibility}
|
||||
onChange={(e) => props.onBucketEditInputChange('visibility', e.currentTarget.value)}
|
||||
disabled={props.storagePending}
|
||||
>
|
||||
<option value="private">Private</option>
|
||||
<option value="public">Public</option>
|
||||
</Select>
|
||||
<div class="flex flex-col gap-2">
|
||||
<Button type="submit" variant="primary" size="sm" disabled={props.storagePending}>
|
||||
Update
|
||||
</Button>
|
||||
<Button type="button" variant="danger" size="sm" onClick={props.onDeleteBucket} disabled={props.storagePending}>
|
||||
Delete Bucket
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* Objects List */}
|
||||
<div class="lg:col-span-2">
|
||||
<Show
|
||||
when={props.selectedBucketID}
|
||||
fallback={
|
||||
<Card class="p-12">
|
||||
<EmptyState
|
||||
icon={
|
||||
<svg class="w-16 h-16" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 3h4m-4 4h4" />
|
||||
</svg>
|
||||
}
|
||||
title="Select a bucket"
|
||||
description="Choose a bucket from the left to view its contents"
|
||||
/>
|
||||
</Card>
|
||||
}
|
||||
<div class="page-actions">
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
onClick={() => setCreateOpen(true)}
|
||||
disabled={!props.canUpdateBucket}
|
||||
>
|
||||
<Card>
|
||||
<div class="p-4 border-b border-gray-200">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold">Objects</h2>
|
||||
<Button size="sm" onClick={() => setShowUploadModal(true)}>
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||
</svg>
|
||||
Upload
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show
|
||||
when={(props.objects?.length ?? 0) > 0}
|
||||
fallback={
|
||||
<div class="p-12">
|
||||
<EmptyState
|
||||
icon={
|
||||
<svg class="w-16 h-16" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||
</svg>
|
||||
}
|
||||
title="No objects"
|
||||
description="Upload files to this bucket"
|
||||
action={
|
||||
<Button onClick={() => setShowUploadModal(true)}>Upload File</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Size</th>
|
||||
<th>Type</th>
|
||||
<th>Modified</th>
|
||||
<th class="text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={props.objects}>
|
||||
{(object) => (
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td>
|
||||
<button
|
||||
onClick={() => {
|
||||
props.onSelectObject(object.object_key);
|
||||
setShowObjectPreview(true);
|
||||
}}
|
||||
class="flex items-center gap-2 text-left hover:text-blue-600"
|
||||
>
|
||||
<svg class="w-5 h-5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<span class="font-medium">{object.object_key}</span>
|
||||
</button>
|
||||
</td>
|
||||
<td class="text-sm text-gray-600">{props.formatBytes(object.size_bytes)}</td>
|
||||
<td>
|
||||
<Badge variant="secondary" class="text-xs">
|
||||
{object.content_type || 'unknown'}
|
||||
</Badge>
|
||||
</td>
|
||||
<td class="text-sm text-gray-600">{props.formatDate(object.updated_at)}</td>
|
||||
<td class="text-right">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => props.onDownloadObject(props.selectedBucketID!, object.object_key)}
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => props.onDeleteObject(object.object_key)}
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</Table>
|
||||
|
||||
{/* Pagination */}
|
||||
<Show when={props.objectsPage && props.objectsPage.total > props.objectsPage.limit}>
|
||||
<div class="p-4 border-t border-gray-200 flex items-center justify-between">
|
||||
<p class="text-sm text-gray-600">
|
||||
Showing {props.objectsPage!.offset + 1} to {Math.min(props.objectsPage!.offset + props.objectsPage!.limit, props.objectsPage!.total)} of {props.objectsPage!.total}
|
||||
</p>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={props.objectsPage!.offset === 0}
|
||||
onClick={() => props.onObjectPageChange(Math.max(0, props.objectsPage!.offset - props.objectsPage!.limit))}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={props.objectsPage!.offset + props.objectsPage!.limit >= props.objectsPage!.total}
|
||||
onClick={() => props.onObjectPageChange(props.objectsPage!.offset + props.objectsPage!.limit)}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</Card>
|
||||
</Show>
|
||||
<IconPlus class="w-4 h-4" />
|
||||
New bucket
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={props.storageMessage}>
|
||||
<Message variant="neutral">{props.storageMessage}</Message>
|
||||
<div class="message message-neutral">{props.storageMessage}</div>
|
||||
</Show>
|
||||
|
||||
{/* Create Bucket Modal */}
|
||||
<Modal open={showCreateBucketModal()} onClose={() => setShowCreateBucketModal(false)} title="Create New Bucket">
|
||||
<form class="space-y-4" onSubmit={handleCreateBucket}>
|
||||
<div class="storage-grid">
|
||||
{/* Bucket list */}
|
||||
<div class="card card-flush">
|
||||
<div
|
||||
class="card-header"
|
||||
style="padding:1rem 1rem 0.75rem;margin-bottom:0;border-bottom:1px solid var(--border)"
|
||||
>
|
||||
<span class="card-header-title">Buckets</span>
|
||||
</div>
|
||||
<div class="p-1.5">
|
||||
<For
|
||||
each={props.buckets ?? []}
|
||||
fallback={
|
||||
<div class="p-4 text-center">
|
||||
<p class="text-xs text-text-3">No buckets yet</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{(bucket) => (
|
||||
<button
|
||||
class={`nav-item w-full ${bucket.id === props.selectedBucketID ? "active" : ""}`}
|
||||
onClick={() => props.onSelectBucket(bucket.id)}
|
||||
>
|
||||
<IconStorage class="w-4 h-4" />
|
||||
<span class="flex-1 truncate">{bucket.name}</span>
|
||||
<Badge variant={bucket.visibility === "public" ? "success" : "neutral"}>
|
||||
{bucket.visibility}
|
||||
</Badge>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Objects */}
|
||||
<div class="card card-flush" style="min-height:28rem">
|
||||
<Show
|
||||
when={activeBucket()}
|
||||
fallback={
|
||||
<div class="empty-state" style="min-height:28rem">
|
||||
<div class="empty-state-icon">
|
||||
<IconStorage class="w-5 h-5" />
|
||||
</div>
|
||||
<p class="empty-state-title">Select a bucket</p>
|
||||
<p class="empty-state-description">
|
||||
Choose a bucket on the left to browse and manage its objects.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div
|
||||
class="card-header"
|
||||
style="padding:1rem 1.25rem;margin-bottom:0;border-bottom:1px solid var(--border)"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3 w-full flex-wrap">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="card-header-title">{activeBucket()!.name}</span>
|
||||
<Badge variant={activeBucket()!.visibility === "public" ? "success" : "neutral"}>
|
||||
{activeBucket()!.visibility}
|
||||
</Badge>
|
||||
</div>
|
||||
<span class="card-header-description">
|
||||
<code>{activeBucket()!.slug}</code> · {pageMeta()?.total ?? props.objects.length} objects
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="icon-btn"
|
||||
title="Bucket settings"
|
||||
onClick={() => setBucketSettingsOpen(true)}
|
||||
disabled={!props.canUpdateBucket}
|
||||
>
|
||||
<IconEdit class="w-4 h-4" />
|
||||
</button>
|
||||
<label class="btn btn-secondary btn-sm" style="cursor:pointer">
|
||||
<IconUpload class="w-3.5 h-3.5" />
|
||||
Upload
|
||||
<input
|
||||
type="file"
|
||||
class="hidden"
|
||||
onChange={(e) => handleFiles(e.currentTarget.files)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* drop zone */}
|
||||
<div
|
||||
class={`mx-4 mt-3 rounded-lg border border-dashed px-4 py-3 text-center text-xs transition-colors ${
|
||||
dragging() ? "text-accent" : "text-text-3"
|
||||
}`}
|
||||
style={`border-color:${dragging() ? "var(--accent)" : "var(--border-strong)"};background:${dragging() ? "var(--accent-muted)" : "transparent"}`}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setDragging(true);
|
||||
}}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setDragging(false);
|
||||
handleFiles(e.dataTransfer?.files ?? null);
|
||||
}}
|
||||
>
|
||||
Drag & drop a file here to upload it to <code>{activeBucket()!.slug}</code>
|
||||
</div>
|
||||
|
||||
<div class="px-4 pt-3">
|
||||
<Input
|
||||
placeholder="Filter objects…"
|
||||
class="input-sm"
|
||||
value={objectSearch()}
|
||||
onInput={(e) => setObjectSearch(e.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="table-container mt-1">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Key</th>
|
||||
<th style="width:1%">Type</th>
|
||||
<th style="width:1%">Size</th>
|
||||
<th style="width:1%">Created</th>
|
||||
<th style="width:1%" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For
|
||||
each={filteredObjects()}
|
||||
fallback={
|
||||
<tr>
|
||||
<td colspan={5} class="py-10 text-center text-text-3">
|
||||
This bucket is empty. Upload your first object.
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
>
|
||||
{(object) => (
|
||||
<tr
|
||||
class={`clickable ${object.object_key === props.selectedObjectKey ? "bg-surface-2" : ""}`}
|
||||
onClick={() => props.onSelectObject(object.object_key)}
|
||||
>
|
||||
<td>
|
||||
<div class="flex items-center gap-2.5">
|
||||
<IconFile class="w-4 h-4 text-text-3 flex-shrink-0" />
|
||||
<span class="font-mono text-xs text-text-1 truncate" style="max-width:22rem">
|
||||
{object.object_key}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-xs text-text-3 whitespace-nowrap">{object.content_type}</td>
|
||||
<td class="text-xs text-text-2 whitespace-nowrap">{props.formatBytes(object.size_bytes)}</td>
|
||||
<td class="text-xs text-text-3 whitespace-nowrap">{props.formatDate(object.created_at)}</td>
|
||||
<td>
|
||||
<IconChevronRight class="w-4 h-4 text-text-3" />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Show when={totalPages() > 1}>
|
||||
<div class="flex items-center justify-between px-4 py-3 border-t" style="border-color:var(--border)">
|
||||
<span class="text-xs text-text-3">
|
||||
Page {currentPage()} of {totalPages()}
|
||||
</span>
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
class="btn btn-ghost btn-sm"
|
||||
disabled={currentPage() <= 1}
|
||||
onClick={() => props.onObjectPageChange((currentPage() - 2) * OBJECTS_PAGE_SIZE)}
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-ghost btn-sm"
|
||||
disabled={!pageMeta()?.has_more}
|
||||
onClick={() => props.onObjectPageChange(currentPage() * OBJECTS_PAGE_SIZE)}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Object inspector */}
|
||||
<Show when={selectedObject()}>
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="flex items-center justify-between w-full gap-3 flex-wrap">
|
||||
<div class="min-w-0">
|
||||
<div class="card-header-title font-mono" style="font-size:0.875rem">
|
||||
{selectedObject()!.object_key}
|
||||
</div>
|
||||
<div class="card-header-description">
|
||||
{selectedObject()!.content_type} · {props.formatBytes(selectedObject()!.size_bytes)} ·{" "}
|
||||
{props.formatDate(selectedObject()!.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="btn btn-secondary btn-sm"
|
||||
onClick={() =>
|
||||
props.onDownloadObject(props.selectedBucketID!, selectedObject()!.object_key)
|
||||
}
|
||||
>
|
||||
<IconDownload class="w-3.5 h-3.5" />
|
||||
Download
|
||||
</button>
|
||||
<Show when={activeBucket()?.visibility === "public"}>
|
||||
<button
|
||||
class="btn btn-secondary btn-sm"
|
||||
onClick={() => props.onCopyObjectURL(selectedObject()!)}
|
||||
>
|
||||
<IconLink class="w-3.5 h-3.5" />
|
||||
Copy URL
|
||||
</button>
|
||||
</Show>
|
||||
<button
|
||||
class="btn btn-danger btn-sm"
|
||||
onClick={() => props.onDeleteObject(selectedObject()!.object_key)}
|
||||
disabled={!props.canUpdateBucket}
|
||||
>
|
||||
<IconTrash class="w-3.5 h-3.5" />
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
{/* preview */}
|
||||
<div>
|
||||
<p class="label">Preview</p>
|
||||
<div class="card card-flush flex items-center justify-center" style="min-height:12rem;background:var(--bg-panel)">
|
||||
<Show
|
||||
when={!props.previewLoading}
|
||||
fallback={<span class="spinner" />}
|
||||
>
|
||||
<Show
|
||||
when={props.objectPreview?.kind === "image"}
|
||||
fallback={
|
||||
<Show
|
||||
when={props.objectPreview?.kind === "text"}
|
||||
fallback={
|
||||
<div class="p-6 text-center">
|
||||
<IconEye class="w-6 h-6 mx-auto text-text-3 mb-2" />
|
||||
<p class="text-xs text-text-3">
|
||||
{props.objectPreview?.kind === "unsupported"
|
||||
? props.objectPreview.message
|
||||
: "Select an object to preview"}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<pre class="code-block w-full border-0" style="max-height:20rem;overflow:auto;border-radius:0">
|
||||
{(props.objectPreview as { kind: "text"; text: string }).text}
|
||||
</pre>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<img
|
||||
src={(props.objectPreview as { kind: "image"; objectURL: string }).objectURL}
|
||||
alt={selectedObject()!.object_key}
|
||||
style="max-height:20rem;max-width:100%;object-fit:contain"
|
||||
/>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* move/copy */}
|
||||
<Show when={props.canUpdateBucket}>
|
||||
<div>
|
||||
<p class="label">Rename, move or copy</p>
|
||||
<div class="space-y-3">
|
||||
<Input
|
||||
label="New object key"
|
||||
value={props.renameObjectKey}
|
||||
onInput={(e) => props.onRenameObjectKeyChange(e.currentTarget.value)}
|
||||
/>
|
||||
<Select
|
||||
label="Destination bucket"
|
||||
value={props.moveDestinationBucketID}
|
||||
onChange={(e) => props.onMoveDestinationChange(e.currentTarget.value)}
|
||||
>
|
||||
<For each={props.buckets ?? []}>
|
||||
{(bucket) => (
|
||||
<option value={bucket.id}>{bucket.name}</option>
|
||||
)}
|
||||
</For>
|
||||
</Select>
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-secondary btn-sm" onClick={props.onMoveObject}>
|
||||
Move / rename
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-sm" onClick={props.onCopyObject}>
|
||||
<IconCopy class="w-3.5 h-3.5" />
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Create bucket modal */}
|
||||
<Modal
|
||||
open={createOpen()}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
title="Create bucket"
|
||||
description="Buckets are folders on the server's filesystem with metadata in Postgres."
|
||||
>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
props.onCreateBucket();
|
||||
setCreateOpen(false);
|
||||
}}
|
||||
class="space-y-4"
|
||||
>
|
||||
<Input
|
||||
label="Bucket Name"
|
||||
placeholder="my-bucket"
|
||||
label="Bucket name"
|
||||
placeholder="User avatars"
|
||||
value={props.bucketInput.name}
|
||||
onInput={(e) => {
|
||||
const name = e.currentTarget.value;
|
||||
props.onBucketInputChange('name', name);
|
||||
const slug = name.toLowerCase().trim().replace(/[^a-z0-9\s-]/g, '').replace(/\s+/g, '-');
|
||||
props.onBucketInputChange('slug', slug);
|
||||
}}
|
||||
disabled={props.storagePending}
|
||||
onInput={(e) => props.onBucketInputChange("name", e.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Bucket Slug"
|
||||
placeholder="my-bucket"
|
||||
label="Slug"
|
||||
placeholder="avatars"
|
||||
value={props.bucketInput.slug}
|
||||
onInput={(e) => props.onBucketInputChange('slug', e.currentTarget.value)}
|
||||
disabled={props.storagePending}
|
||||
onInput={(e) => props.onBucketInputChange("slug", e.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
<Select
|
||||
label="Visibility"
|
||||
value={props.bucketInput.visibility}
|
||||
onChange={(e) => props.onBucketInputChange('visibility', e.currentTarget.value)}
|
||||
disabled={props.storagePending}
|
||||
onChange={(e) => props.onBucketInputChange("visibility", e.currentTarget.value)}
|
||||
>
|
||||
<option value="private">Private</option>
|
||||
<option value="public">Public</option>
|
||||
<option value="private">private — authenticated download only</option>
|
||||
<option value="public">public — anyone with the URL can download</option>
|
||||
</Select>
|
||||
<div class="flex gap-3 justify-end pt-4">
|
||||
<Button type="button" variant="ghost" onClick={() => setShowCreateBucketModal(false)}>
|
||||
<ModalFooter>
|
||||
<button type="button" class="btn btn-ghost" onClick={() => setCreateOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" disabled={props.storagePending}>
|
||||
{props.storagePending ? "Creating..." : "Create Bucket"}
|
||||
</Button>
|
||||
</div>
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary" disabled={props.storagePending}>
|
||||
Create bucket
|
||||
</button>
|
||||
</ModalFooter>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
{/* Upload Modal */}
|
||||
<Modal open={showUploadModal()} onClose={() => setShowUploadModal(false)} title="Upload File">
|
||||
<div class="space-y-4">
|
||||
<FileInput
|
||||
label="Select File"
|
||||
onChange={(file) => setSelectedFile(file)}
|
||||
accept="*/*"
|
||||
/>
|
||||
<Show when={selectedFile()}>
|
||||
<div class="p-4 bg-gray-50 rounded-lg">
|
||||
<p class="text-sm font-medium text-gray-900">{selectedFile()!.name}</p>
|
||||
<p class="text-xs text-gray-600 mt-1">
|
||||
{props.formatBytes(selectedFile()!.size)} • {selectedFile()!.type || 'unknown type'}
|
||||
</p>
|
||||
</div>
|
||||
</Show>
|
||||
<div class="flex gap-3 justify-end pt-4">
|
||||
<Button type="button" variant="ghost" onClick={() => setShowUploadModal(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!selectedFile() || props.storagePending}
|
||||
onClick={handleUpload}
|
||||
>
|
||||
{props.storagePending ? "Uploading..." : "Upload"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Object Preview Modal */}
|
||||
<Modal
|
||||
open={showObjectPreview()}
|
||||
onClose={() => setShowObjectPreview(false)}
|
||||
title="Object Preview"
|
||||
size="lg"
|
||||
{/* Bucket settings modal */}
|
||||
<Modal
|
||||
open={bucketSettingsOpen()}
|
||||
onClose={() => setBucketSettingsOpen(false)}
|
||||
title="Bucket settings"
|
||||
>
|
||||
<Show when={props.objectPreview}>
|
||||
<div class="space-y-4">
|
||||
<Show when={props.objectPreview.kind === 'image'}>
|
||||
<img src={props.objectPreview.objectURL} alt="Preview" class="w-full rounded-lg" />
|
||||
</Show>
|
||||
<Show when={props.objectPreview.kind === 'text'}>
|
||||
<pre class="p-4 bg-gray-900 text-gray-100 rounded-lg overflow-x-auto text-sm">
|
||||
{props.objectPreview.text}
|
||||
</pre>
|
||||
<Show when={props.objectPreview.truncated}>
|
||||
<p class="text-sm text-yellow-600">Preview truncated. Download to view full content.</p>
|
||||
</Show>
|
||||
</Show>
|
||||
<Show when={props.objectPreview.kind === 'unsupported'}>
|
||||
<div class="text-center p-8">
|
||||
<p class="text-gray-600">{props.objectPreview.message}</p>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
props.onUpdateBucket();
|
||||
setBucketSettingsOpen(false);
|
||||
}}
|
||||
class="space-y-4"
|
||||
>
|
||||
<Input
|
||||
label="Bucket name"
|
||||
value={props.bucketEditInput.name}
|
||||
onInput={(e) => props.onBucketEditInputChange("name", e.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Slug"
|
||||
value={props.bucketEditInput.slug}
|
||||
onInput={(e) => props.onBucketEditInputChange("slug", e.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
<Select
|
||||
label="Visibility"
|
||||
value={props.bucketEditInput.visibility}
|
||||
onChange={(e) => props.onBucketEditInputChange("visibility", e.currentTarget.value)}
|
||||
>
|
||||
<option value="private">private</option>
|
||||
<option value="public">public</option>
|
||||
</Select>
|
||||
<ModalFooter align="between">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-danger"
|
||||
onClick={() => {
|
||||
setBucketSettingsOpen(false);
|
||||
props.onDeleteBucket();
|
||||
}}
|
||||
>
|
||||
Delete bucket
|
||||
</button>
|
||||
<div class="flex gap-2">
|
||||
<button type="button" class="btn btn-ghost" onClick={() => setBucketSettingsOpen(false)}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary" disabled={props.storagePending}>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
</form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
// Pages barrel export
|
||||
export { LoginPage } from "./LoginPage";
|
||||
export { ProjectsPage } from "./ProjectsPage";
|
||||
export { MembersPage } from "./MembersPage";
|
||||
export { StoragePage } from "./StoragePage";
|
||||
export { CollectionsPage } from "./CollectionsPage";
|
||||
export { SettingsPage } from "./SettingsPage";
|
||||
export { AuditPage } from "./AuditPage";
|
||||
export { AuthPage, type AuthUser } from "./AuthPage";
|
||||
Reference in new issue
Block a user