first commit

This commit is contained in:
KodekaTeam committed 2026-06-02 00:17:50 +07:00
1 parent abb09fbea8
commit 72f6d49818
355 files changed
+121239 -3

No files matched your search

+182
View File
@@ -0,0 +1,182 @@
"use client";
import type { ReactNode } from "react";
import { AlertTriangle, X } from "lucide-react";
type ConfirmActionDialogProps = {
open: boolean;
title: string;
description: string;
confirmLabel: string;
cancelLabel?: string;
tone?: "danger" | "warning" | "info";
note?: string;
icon?: ReactNode;
onClose: () => void;
onConfirm: () => void;
};
const toneStyles = {
danger: {
color: "#ef4444",
softBackground: "rgba(239,68,68,0.08)",
strongBackground: "rgba(239,68,68,0.12)",
border: "rgba(239,68,68,0.22)",
lightBorder: "rgba(239,68,68,0.18)",
},
warning: {
color: "#f59e0b",
softBackground: "rgba(245,158,11,0.08)",
strongBackground: "rgba(245,158,11,0.12)",
border: "rgba(245,158,11,0.22)",
lightBorder: "rgba(245,158,11,0.18)",
},
info: {
color: "#3b82f6",
softBackground: "rgba(59,130,246,0.08)",
strongBackground: "rgba(59,130,246,0.12)",
border: "rgba(59,130,246,0.22)",
lightBorder: "rgba(59,130,246,0.18)",
},
} as const;
export default function ConfirmActionDialog({
open,
title,
description,
confirmLabel,
cancelLabel = "Cancel",
tone = "danger",
note,
icon,
onClose,
onConfirm,
}: ConfirmActionDialogProps) {
if (!open) {
return null;
}
const palette = toneStyles[tone];
return (
<div
style={{
position: "fixed",
inset: 0,
background: "rgba(2, 6, 23, 0.68)",
backdropFilter: "blur(6px)",
zIndex: 2200,
display: "grid",
placeItems: "center",
padding: 20,
}}
onClick={onClose}
>
<div
className="card"
style={{
width: "min(100%, 520px)",
padding: 0,
overflow: "hidden",
boxShadow: "0 28px 70px rgba(2, 6, 23, 0.46)",
}}
onClick={(event) => event.stopPropagation()}
>
<div style={{ padding: 28, display: "grid", gap: 18 }}>
<div
style={{
display: "flex",
justifyContent: "space-between",
gap: 12,
}}
>
<div style={{ display: "grid", gap: 8 }}>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<div
style={{
width: 34,
height: 34,
borderRadius: 10,
border: `1px solid ${palette.lightBorder}`,
background: palette.softBackground,
display: "grid",
placeItems: "center",
color: palette.color,
}}
>
{icon ?? <AlertTriangle size={16} />}
</div>
<strong style={{ color: "var(--text-primary)", fontSize: 18 }}>
{title}
</strong>
</div>
<p
style={{
color: "var(--text-muted)",
fontSize: 14,
lineHeight: 1.7,
maxWidth: 430,
}}
>
{description}
</p>
</div>
<button
onClick={onClose}
style={{
background: "none",
border: "none",
cursor: "pointer",
color: "var(--text-muted)",
alignSelf: "flex-start",
}}
aria-label="Close confirmation dialog"
>
<X size={18} />
</button>
</div>
{note ? (
<div
style={{
borderRadius: 12,
border: `1px solid ${palette.lightBorder}`,
background: palette.softBackground,
padding: "14px 16px",
color: palette.color,
fontSize: 13,
lineHeight: 1.7,
}}
>
{note}
</div>
) : null}
<div
style={{
display: "flex",
gap: 10,
justifyContent: "flex-end",
}}
>
<button className="btn" onClick={onClose}>
{cancelLabel}
</button>
<button
className="btn"
onClick={onConfirm}
style={{
background: palette.strongBackground,
color: palette.color,
border: `1px solid ${palette.border}`,
}}
>
{icon ?? <AlertTriangle size={14} />}
{confirmLabel}
</button>
</div>
</div>
</div>
</div>
);
}
+117
View File
@@ -0,0 +1,117 @@
"use client";
import { useState, useEffect } from "react";
import Sidebar from "@/components/Sidebar";
import Topbar from "@/components/Topbar";
import ReadOnlyAccessNotice from "@/components/ReadOnlyAccessNotice";
import ReadOnlyActionScope from "@/components/ReadOnlyActionScope";
import { useCurrentUser, useRequireAuth } from "@/lib/auth-state";
import { addPreferencesListener, getStoredPanelName } from "@/lib/preferences";
import { getRoleCapabilities } from "@/lib/rbac";
interface DashboardLayoutProps {
children: React.ReactNode;
title: string;
subtitle?: string;
}
export default function DashboardLayout({
children,
title,
subtitle,
}: DashboardLayoutProps) {
const isAuthenticated = useRequireAuth();
const currentUser = useCurrentUser();
const roleCapabilities = getRoleCapabilities(currentUser?.role);
const [collapsed, setCollapsed] = useState(false);
const [isMobile, setIsMobile] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false);
useEffect(() => {
const checkMobile = () => {
const mobile = window.innerWidth < 768;
setIsMobile(mobile);
if (mobile) setCollapsed(true);
};
checkMobile();
window.addEventListener("resize", checkMobile);
return () => window.removeEventListener("resize", checkMobile);
}, []);
useEffect(() => {
const syncTitle = () => {
document.title = `${title} | ${getStoredPanelName()}`;
};
syncTitle();
return addPreferencesListener(syncTitle);
}, [title]);
if (!isAuthenticated) {
return null;
}
return (
<div style={{ display: "flex", height: "100vh", overflow: "hidden" }}>
{/* Floating particles */}
<div className="particles-bg">
{[...Array(8)].map((_, i) => (
<div
key={i}
className="particle"
style={{
left: `${10 + i * 12}%`,
top: `${20 + (i % 3) * 25}%`,
animationDelay: `${i * 0.5}s`,
opacity: 0.15 + (i % 3) * 0.1,
width: 3 + (i % 2) * 2,
height: 3 + (i % 2) * 2,
}}
/>
))}
</div>
{/* Sidebar — handles its own backdrop overlay for mobile */}
<Sidebar
collapsed={collapsed}
onToggle={() => setCollapsed(!collapsed)}
isMobile={isMobile}
mobileOpen={mobileOpen}
onMobileClose={() => setMobileOpen(false)}
/>
{/* Main content area */}
<div
style={{
flex: 1,
display: "flex",
flexDirection: "column",
overflow: "hidden",
minWidth: 0,
}}
>
<Topbar
title={title}
subtitle={subtitle}
onMobileMenuToggle={
isMobile ? () => setMobileOpen(!mobileOpen) : undefined
}
/>
<main
className="p-4 md:p-6"
style={{
flex: 1,
overflowY: "auto",
overflowX: "hidden",
padding: 15,
}}
>
<ReadOnlyActionScope enabled={roleCapabilities.isReadOnly}>
{roleCapabilities.isReadOnly ? <ReadOnlyAccessNotice /> : null}
{children}
</ReadOnlyActionScope>
</main>
</div>
</div>
);
}
+57
View File
@@ -0,0 +1,57 @@
"use client";
import { useEffect, type ReactNode } from "react";
import { useRouter } from "next/navigation";
import type { UserInfo } from "@/lib/api";
import DashboardLayout from "@/components/DashboardLayout";
import { canAccessRoute } from "@/lib/permissions";
import { useCurrentUser } from "@/lib/auth-state";
interface GuardedPageProps {
route: string;
title: string;
subtitle?: string;
redirectSubtitle?: string;
redirectMessage?: string;
currentUser?: UserInfo | null;
children: ReactNode;
}
export default function GuardedPage({
route,
title,
subtitle,
redirectSubtitle = "Redirecting to a page you can access",
redirectMessage = "You do not have access to this page. Redirecting...",
currentUser: currentUserProp,
children,
}: GuardedPageProps) {
const router = useRouter();
const hookUser = useCurrentUser();
const currentUser = currentUserProp ?? hookUser;
const canViewPage = canAccessRoute(route, currentUser?.role);
useEffect(() => {
if (currentUser && !canViewPage) {
router.replace("/");
}
}, [canViewPage, currentUser, router]);
if (currentUser && !canViewPage) {
return (
<DashboardLayout title={title} subtitle={redirectSubtitle}>
<div className="card" style={{ padding: 24, textAlign: "center" }}>
<p style={{ fontSize: 14, color: "var(--text-secondary)" }}>
{redirectMessage}
</p>
</div>
</DashboardLayout>
);
}
return (
<DashboardLayout title={title} subtitle={subtitle}>
{children}
</DashboardLayout>
);
}
+947
View File
@@ -0,0 +1,947 @@
"use client";
import {
organizationsApi,
type CreateOrganizationBody,
type OrganizationRecord,
} from "@/lib/api";
import {
addOrganizationStateListener,
getStoredOrganizationId,
setStoredOrganizationId,
} from "@/lib/organization-state";
import {
Building2,
ChevronDown,
ChevronsUpDown,
Pencil,
Plus,
Star,
Trash2,
X,
} from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
interface OrganizationSwitcherProps {
collapsed: boolean;
canManage: boolean;
}
const emptyForm: CreateOrganizationBody = {
name: "",
logoUrl: "",
};
let organizationsCache: OrganizationRecord[] | null = null;
let organizationsCacheUpdatedAt = 0;
let organizationsRequest: Promise<OrganizationRecord[]> | null = null;
const ORGANIZATIONS_CACHE_TTL_MS = 30_000;
async function fetchOrganizations() {
if (organizationsRequest) {
return organizationsRequest;
}
organizationsRequest = organizationsApi
.list()
.then((response) => {
const items = response.data ?? [];
organizationsCache = items;
organizationsCacheUpdatedAt = Date.now();
return items;
})
.finally(() => {
organizationsRequest = null;
});
return organizationsRequest;
}
export default function OrganizationSwitcher({
collapsed,
canManage,
}: OrganizationSwitcherProps) {
const [isNarrowViewport, setIsNarrowViewport] = useState(false);
const [organizations, setOrganizations] = useState<OrganizationRecord[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [open, setOpen] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [deleteTarget, setDeleteTarget] = useState<OrganizationRecord | null>(
null,
);
const [deleteConfirmation, setDeleteConfirmation] = useState("");
const [confirmDeleteStep, setConfirmDeleteStep] = useState(false);
const [form, setForm] = useState<CreateOrganizationBody>(emptyForm);
const rootRef = useRef<HTMLDivElement | null>(null);
const loadOrganizations = async (options?: { force?: boolean }) => {
const cacheFresh =
organizationsCache &&
Date.now() - organizationsCacheUpdatedAt < ORGANIZATIONS_CACHE_TTL_MS;
if (organizationsCache) {
setOrganizations(organizationsCache);
}
if (!organizationsCache || options?.force) {
setLoading(true);
} else {
setLoading(false);
}
if (cacheFresh && !options?.force) {
return;
}
setError("");
try {
const items = await fetchOrganizations();
setOrganizations(items);
const storedOrganizationId = getStoredOrganizationId();
const fallbackOrganization =
items.find(
(organization) => organization.id === storedOrganizationId,
) ??
items.find((organization) => organization.isActive) ??
items.find((organization) => organization.isDefault) ??
items[0];
if (
fallbackOrganization?.id &&
fallbackOrganization.id !== storedOrganizationId
) {
setStoredOrganizationId(fallbackOrganization.id);
}
} catch (err) {
setError(
err instanceof Error ? err.message : "Failed to load organizations",
);
} finally {
setLoading(false);
}
};
useEffect(() => {
const syncViewport = () => {
setIsNarrowViewport(window.innerWidth < 1180);
};
const initialLoadTimer = window.setTimeout(() => {
void loadOrganizations();
syncViewport();
}, 0);
const handlePointerDown = (event: MouseEvent) => {
if (!rootRef.current?.contains(event.target as Node)) {
setOpen(false);
}
};
const removeOrganizationListener = addOrganizationStateListener(() => {
void loadOrganizations({ force: true });
});
document.addEventListener("pointerdown", handlePointerDown);
window.addEventListener("resize", syncViewport);
return () => {
window.clearTimeout(initialLoadTimer);
removeOrganizationListener();
document.removeEventListener("pointerdown", handlePointerDown);
window.removeEventListener("resize", syncViewport);
};
}, []);
const activeOrganization = useMemo(() => {
const storedOrganizationId = getStoredOrganizationId();
return (
organizations.find(
(organization) => organization.id === storedOrganizationId,
) ??
organizations.find((organization) => organization.isActive) ??
organizations[0] ??
null
);
}, [organizations]);
const openCreateModal = () => {
setEditingId(null);
setForm(emptyForm);
setModalOpen(true);
setOpen(false);
};
const openEditModal = (organization: OrganizationRecord) => {
setEditingId(organization.id);
setForm({ name: organization.name, logoUrl: organization.logoUrl ?? "" });
setModalOpen(true);
setOpen(false);
};
const resetDeleteModal = () => {
setDeleteTarget(null);
setDeleteConfirmation("");
setConfirmDeleteStep(false);
};
const closeDeleteModal = () => {
if (saving) {
return;
}
resetDeleteModal();
};
const openDeleteModal = (organization: OrganizationRecord) => {
setDeleteTarget(organization);
setDeleteConfirmation("");
setConfirmDeleteStep(false);
setError("");
setOpen(false);
};
const handleSelect = async (organization: OrganizationRecord) => {
if (organization.id === activeOrganization?.id) {
setOpen(false);
return;
}
setSaving(true);
setError("");
const previousOrganizationId = activeOrganization?.id;
setStoredOrganizationId(organization.id);
try {
await organizationsApi.activate(organization.id);
setOpen(false);
window.location.reload();
} catch (err) {
if (previousOrganizationId) {
setStoredOrganizationId(previousOrganizationId);
}
setError(
err instanceof Error ? err.message : "Failed to switch organization",
);
} finally {
setSaving(false);
}
};
const handleSetDefault = async (organization: OrganizationRecord) => {
setSaving(true);
setError("");
try {
await organizationsApi.setDefault(organization.id);
if (organization.id === activeOrganization?.id) {
setStoredOrganizationId(organization.id);
}
await loadOrganizations({ force: true });
} catch (err) {
setError(
err instanceof Error
? err.message
: "Failed to set default organization",
);
} finally {
setSaving(false);
}
};
const handleDelete = async (organization: OrganizationRecord) => {
setSaving(true);
setError("");
const wasActive = organization.id === activeOrganization?.id;
try {
await organizationsApi.remove(organization.id, {
confirmation: "DELETE",
});
await loadOrganizations({ force: true });
resetDeleteModal();
if (wasActive) {
window.location.reload();
return;
}
} catch (err) {
setError(
err instanceof Error ? err.message : "Failed to delete organization",
);
} finally {
setSaving(false);
}
};
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setSaving(true);
setError("");
try {
const payload = {
name: form.name.trim(),
logoUrl: form.logoUrl?.trim() || undefined,
};
if (editingId) {
await organizationsApi.update(editingId, payload);
} else {
await organizationsApi.create(payload);
}
setModalOpen(false);
setEditingId(null);
setForm(emptyForm);
await loadOrganizations({ force: true });
} catch (err) {
setError(
err instanceof Error ? err.message : "Failed to save organization",
);
} finally {
setSaving(false);
}
};
if (collapsed) return null;
return (
<div ref={rootRef} style={{ position: "relative", margin: "12px 8px 8px" }}>
<button
onClick={() => setOpen((value) => !value)}
style={{
width: "100%",
display: "flex",
alignItems: "center",
gap: 10,
padding: "10px 12px",
borderRadius: 8,
border: "1px solid var(--border)",
// background: "rgba(255,255,255,0.04)",
background: activeOrganization
? "transparent"
: "var(--bg-secondary)",
color: "var(--text-primary)",
cursor: "pointer",
textAlign: "left",
}}
>
{activeOrganization?.logoUrl ? (
<img
src={activeOrganization.logoUrl}
alt={activeOrganization.name}
style={{
width: 20,
height: 20,
borderRadius: 999,
objectFit: "cover",
}}
/>
) : (
<div
style={{
width: 20,
height: 20,
borderRadius: 999,
border: "1px solid var(--border)",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "var(--text-muted)",
}}
>
<Building2 size={12} />
</div>
)}
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: 12,
color: "var(--text-muted)",
marginBottom: 2,
}}
>
Organization
</div>
<div
style={{
fontSize: 14,
fontWeight: 600,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{activeOrganization?.name ??
(loading ? "Loading..." : "No organization")}
</div>
</div>
<ChevronsUpDown size={16} color="var(--text-muted)" />
</button>
{open && (
<div
style={{
position: "absolute",
top: isNarrowViewport ? "calc(100% + 8px)" : 0,
left: isNarrowViewport ? 0 : "calc(100% + 14px)",
minHeight: "250px",
maxHeight: "350px",
width: 300,
background: "var(--bg-secondary)",
border: "1px solid var(--border)",
borderRadius: 8,
boxShadow: "0 16px 40px rgba(0,0,0,0.22)",
display: "flex",
flexDirection: "column",
overflow: "hidden",
padding: 12,
zIndex: 60,
}}
>
<div
style={{
fontSize: 13,
display: "flex",
alignItems: "center",
gap: 2,
color: "var(--text-muted)",
padding: "2px 6px 10px",
}}
>
<Building2 size={12} style={{ marginRight: 4 }} />
Organizations
</div>
<div
style={{
display: "flex",
flexDirection: "column",
gap: 4,
flex: "1 1 auto",
minHeight: 0,
overflowY: "auto",
paddingRight: 4,
}}
>
{organizations.map((organization) => {
const selected = organization.id === activeOrganization?.id;
return (
<div
key={organization.id}
role="button"
tabIndex={0}
onClick={() => void handleSelect(organization)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
void handleSelect(organization);
}
}}
style={{
width: "100%",
display: "flex",
alignItems: "center",
gap: 10,
padding: "12px 10px",
borderRadius: 12,
background: selected
? "rgba(59, 130, 246, 0.12)"
: "transparent",
color: "var(--text-primary)",
cursor: "pointer",
textAlign: "left",
outline: "none",
}}
>
{organization.logoUrl ? (
<img
src={organization.logoUrl}
alt={organization.name}
style={{
width: 18,
height: 18,
borderRadius: 999,
objectFit: "cover",
}}
/>
) : (
<div
style={{
width: 18,
height: 18,
borderRadius: 999,
border: "1px solid var(--border)",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "var(--text-muted)",
}}
>
<Building2 size={11} />
</div>
)}
<div
style={{
flex: 1,
minWidth: 0,
}}
>
<div
style={{
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
fontSize: 15,
fontWeight: 500,
}}
>
{organization.name}
</div>
</div>
<button
type="button"
title="Set default organization"
disabled={saving}
onClick={(event) => {
event.stopPropagation();
void handleSetDefault(organization);
}}
style={{
border: "none",
background: "transparent",
color: organization.isDefault
? "#f5c451"
: "var(--text-muted)",
cursor: "pointer",
padding: 2,
}}
>
<Star
size={15}
fill={organization.isDefault ? "currentColor" : "none"}
/>
</button>
{canManage && (
<button
type="button"
title="Edit organization"
disabled={saving}
onClick={(event) => {
event.stopPropagation();
openEditModal(organization);
}}
style={{
border: "none",
background: "transparent",
color: "var(--text-muted)",
cursor: "pointer",
padding: 2,
}}
>
<Pencil size={15} />
</button>
)}
{canManage && organizations.length > 1 && (
<button
type="button"
title={
selected
? "Current organization cannot be deleted"
: "Delete organization"
}
disabled={saving || selected}
onClick={(event) => {
event.stopPropagation();
if (selected) {
return;
}
openDeleteModal(organization);
}}
style={{
border: "none",
background: "transparent",
color: "var(--text-muted)",
cursor: saving || selected ? "not-allowed" : "pointer",
opacity: selected ? 0.45 : 1,
padding: 2,
}}
>
<Trash2 size={15} />
</button>
)}
</div>
);
})}
</div>
{canManage && (
<button
type="button"
onClick={openCreateModal}
disabled={saving}
style={{
marginTop: 10,
width: "100%",
display: "flex",
alignItems: "center",
gap: 8,
padding: "8px 6px",
borderRadius: 7,
border: "1px solid var(--border)",
background: "transparent",
color: "var(--text-secondary)",
cursor: "pointer",
}}
>
<Plus size={14} /> Add organization
</button>
)}
{error && (
<div style={{ marginTop: 8, fontSize: 12, color: "#ef4444" }}>
{error}
</div>
)}
</div>
)}
{deleteTarget && (
<div
style={{
position: "fixed",
inset: 0,
background: "rgba(2, 6, 23, 0.68)",
display: "grid",
placeItems: "center",
padding: 20,
zIndex: 90,
}}
onClick={closeDeleteModal}
>
<div
className="card"
style={{
width: "min(100%, 520px)",
padding: 0,
overflow: "hidden",
boxShadow: "0 28px 70px rgba(2, 6, 23, 0.46)",
}}
onClick={(event) => event.stopPropagation()}
>
{!confirmDeleteStep ? (
<div style={{ padding: 28, display: "grid", gap: 18 }}>
<div
style={{
display: "flex",
justifyContent: "space-between",
gap: 12,
}}
>
<div style={{ display: "grid", gap: 8 }}>
<div
style={{ display: "flex", alignItems: "center", gap: 10 }}
>
<div
style={{
width: 34,
height: 34,
borderRadius: 10,
border: "1px solid rgba(239,68,68,0.2)",
background: "rgba(239,68,68,0.08)",
display: "grid",
placeItems: "center",
color: "#ef4444",
}}
>
<Trash2 size={16} />
</div>
<strong
style={{ color: "var(--text-primary)", fontSize: 18 }}
>
Delete Organization
</strong>
</div>
<p
style={{
color: "var(--text-muted)",
fontSize: 14,
lineHeight: 1.7,
maxWidth: 430,
}}
>
Type DELETE to unlock the final removal confirmation. This
will delete organization data and related records in the
database, including servers, containers, domains, SSL,
networks, backups, security presets, installed apps,
invitations, API keys, and linked access records.
</p>
</div>
<button
onClick={closeDeleteModal}
disabled={saving}
style={{
background: "none",
border: "none",
cursor: saving ? "not-allowed" : "pointer",
color: "var(--text-muted)",
alignSelf: "flex-start",
}}
aria-label="Close delete organization dialog"
>
<X size={18} />
</button>
</div>
<div
style={{
borderRadius: 12,
border: "1px solid rgba(239,68,68,0.18)",
background: "rgba(239,68,68,0.08)",
padding: "14px 16px",
color: "#f87171",
fontSize: 13,
lineHeight: 1.7,
}}
>
Target: {deleteTarget.name}. If some related records do not
exist in the database, they will be skipped automatically.
</div>
<input
className="input"
placeholder="Type DELETE to confirm"
value={deleteConfirmation}
onChange={(event) => {
const nextValue = event.target.value;
setDeleteConfirmation(nextValue);
if (nextValue.trim() !== "DELETE") {
setConfirmDeleteStep(false);
}
}}
autoFocus
/>
<div style={{ display: "flex", justifyContent: "flex-start" }}>
<button
className="btn btn-sm"
onClick={() => setConfirmDeleteStep(true)}
disabled={deleteConfirmation.trim() !== "DELETE"}
style={{
background: "rgba(239,68,68,0.12)",
color: "#ef4444",
border: "1px solid rgba(239,68,68,0.22)",
}}
>
<Trash2 size={14} /> Continue
</button>
</div>
</div>
) : (
<div style={{ padding: 28, display: "grid", gap: 18 }}>
<div style={{ display: "grid", gap: 8 }}>
<strong
style={{ color: "var(--text-primary)", fontSize: 18 }}
>
Delete Organization
</strong>
<p
style={{
color: "var(--text-muted)",
fontSize: 14,
lineHeight: 1.7,
}}
>
This will permanently remove the organization record and all
related database data tied to {deleteTarget.name}. This
action cannot be undone.
</p>
</div>
<div
style={{
borderRadius: 12,
padding: "14px 16px",
fontSize: 13,
color: "#ef4444",
background: "rgba(239,68,68,0.08)",
border: "1px solid rgba(239,68,68,0.24)",
lineHeight: 1.7,
}}
>
Confirm this action only if you expect permanent database
cleanup for this organization and its related records.
</div>
<div
style={{
display: "flex",
gap: 10,
justifyContent: "flex-end",
}}
>
<button
className="btn"
onClick={() => setConfirmDeleteStep(false)}
disabled={saving}
>
Cancel
</button>
<button
className="btn"
onClick={() => void handleDelete(deleteTarget)}
disabled={saving}
style={{
background: "rgba(239,68,68,0.12)",
color: "#ef4444",
border: "1px solid rgba(239,68,68,0.22)",
}}
>
<Trash2 size={14} />
{saving ? "Deleting..." : "Delete Organization"}
</button>
</div>
</div>
)}
</div>
</div>
)}
{modalOpen && (
<div
style={{
position: "fixed",
inset: 0,
background: "rgba(10, 14, 23, 0.7)",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: 20,
zIndex: 80,
}}
>
<form
onSubmit={(event) => void handleSubmit(event)}
style={{
width: "100%",
maxWidth: 520,
background: "var(--bg-secondary)",
border: "1px solid var(--border)",
borderRadius: 18,
padding: 24,
display: "flex",
flexDirection: "column",
gap: 18,
}}
>
<div>
<div
style={{
fontSize: 28,
fontWeight: 700,
color: "var(--text-primary)",
}}
>
{editingId ? "Edit organization" : "Add organization"}
</div>
<div
style={{
marginTop: 6,
fontSize: 14,
color: "var(--text-muted)",
}}
>
Create a separate workspace for projects, domains, apps,
security, invitations, and API keys.
</div>
</div>
<label style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<span
style={{
fontSize: 14,
fontWeight: 600,
color: "var(--text-primary)",
}}
>
Name *
</span>
<input
className="input"
value={form.name}
onChange={(event) =>
setForm((current) => ({
...current,
name: event.target.value,
}))
}
placeholder="Organization name"
required
/>
</label>
<label style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<span
style={{
fontSize: 14,
fontWeight: 600,
color: "var(--text-primary)",
}}
>
Logo URL
</span>
<input
className="input"
value={form.logoUrl ?? ""}
onChange={(event) =>
setForm((current) => ({
...current,
logoUrl: event.target.value,
}))
}
placeholder="https://example.com/logo.png"
/>
</label>
{error && (
<div style={{ fontSize: 13, color: "#ef4444" }}>{error}</div>
)}
<div
style={{ display: "flex", justifyContent: "flex-end", gap: 10 }}
>
<button
type="button"
className="btn btn-ghost"
onClick={() => {
setModalOpen(false);
setEditingId(null);
setForm(emptyForm);
}}
>
Cancel
</button>
<button
type="submit"
className="btn btn-primary"
disabled={saving}
>
{saving
? "Saving..."
: editingId
? "Save changes"
: "Create organization"}
</button>
</div>
</form>
</div>
)}
</div>
);
}
+518
View File
@@ -0,0 +1,518 @@
"use client";
import {
Check,
Circle,
ImageIcon,
Loader2,
ScrollText,
Terminal,
X,
} from "lucide-react";
import {
type ReactNode,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import Image from "next/image";
import { sanitizeLogText } from "@/lib/terminal-output";
export type ProcessLogStepStatus =
| "pending"
| "running"
| "success"
| "error"
| "skipped";
export type ProcessLogStep = {
id: string;
label: string;
description?: string;
progress?: number | string;
status?: ProcessLogStepStatus;
};
export type ProcessLogsModalState = {
title: string;
description?: string;
timelineLogs?: ProcessLogStep[];
terminalLogs?: string | string[];
imageAlt?: string;
imageUrl?: string;
imageSlot?: ReactNode;
initialTab?: ProcessLogsModalTab;
statusLabel?: string;
};
export type ProcessLogsModalTab = "timeline" | "terminal";
type ProcessLogsModalProps = ProcessLogsModalState & {
open: boolean;
onClose: () => void;
closeOnOverlayClick?: boolean;
};
const defaultTimelineLogs: ProcessLogStep[] = [
{
id: "queued",
label: "Waiting for process data",
progress: 0,
status: "running",
},
];
const statusTone: Record<
ProcessLogStepStatus,
{
color: string;
border: string;
background: string;
icon: ReactNode;
}
> = {
success: {
color: "#16a34a",
border: "rgba(22, 163, 74, 0.55)",
background: "rgba(34, 197, 94, 0.2)",
icon: <Check size={13} strokeWidth={3} />,
},
running: {
color: "#16a34a",
border: "rgba(22, 163, 74, 0.55)",
background: "rgba(34, 197, 94, 0.18)",
icon: <Loader2 size={13} className="animate-spin" />,
},
pending: {
color: "#8b949e",
border: "rgba(148, 163, 184, 0.48)",
background: "rgba(148, 163, 184, 0.12)",
icon: <Circle size={10} />,
},
skipped: {
color: "#8b949e",
border: "rgba(148, 163, 184, 0.42)",
background: "rgba(148, 163, 184, 0.1)",
icon: <Circle size={10} />,
},
error: {
color: "#ef4444",
border: "rgba(239, 68, 68, 0.58)",
background: "rgba(239, 68, 68, 0.16)",
icon: <X size={13} strokeWidth={3} />,
},
};
function formatProgress(progress: ProcessLogStep["progress"]) {
if (progress === undefined || progress === null || progress === "") {
return "";
}
return typeof progress === "number" ? `${progress}%` : progress;
}
export function useProcessLogsModal() {
const [modalState, setModalState] = useState<ProcessLogsModalState | null>(
null,
);
const openProcessLogs = useCallback((state: ProcessLogsModalState) => {
setModalState(state);
}, []);
const updateProcessLogs = useCallback(
(nextState: Partial<ProcessLogsModalState>) => {
setModalState((current) =>
current
? { ...current, ...nextState }
: (nextState as ProcessLogsModalState),
);
},
[],
);
const closeProcessLogs = useCallback(() => {
setModalState(null);
}, []);
return {
modalState,
isProcessLogsOpen: modalState !== null,
openProcessLogs,
updateProcessLogs,
closeProcessLogs,
};
}
export default function ProcessLogsModal({
open,
title,
description = "Detail of the request log entry",
timelineLogs = defaultTimelineLogs,
terminalLogs = "",
imageAlt = "Process preview",
imageUrl,
imageSlot,
initialTab = "timeline",
statusLabel,
closeOnOverlayClick = true,
onClose,
}: ProcessLogsModalProps) {
const [selectedTab, setSelectedTab] = useState<ProcessLogsModalTab | null>(
null,
);
const terminalViewportRef = useRef<HTMLPreElement>(null);
const activeTab = selectedTab ?? initialTab;
const sanitizedTerminalLogs = useMemo(() => {
const rawLogs = Array.isArray(terminalLogs)
? terminalLogs.join("\n")
: terminalLogs;
return sanitizeLogText(rawLogs || "No terminal logs available yet.");
}, [terminalLogs]);
useEffect(() => {
if (!open || activeTab !== "terminal") return;
const viewport = terminalViewportRef.current;
if (!viewport) return;
viewport.scrollTop = viewport.scrollHeight;
}, [activeTab, open, sanitizedTerminalLogs]);
const handleClose = useCallback(() => {
setSelectedTab(null);
onClose();
}, [onClose]);
if (!open) {
return null;
}
const latestActiveStep =
[...timelineLogs]
.reverse()
.find((step) => step.status === "running" || step.status === "success") ??
timelineLogs[0];
const progressLabel =
statusLabel ?? formatProgress(latestActiveStep?.progress);
const progressBarWidth =
typeof latestActiveStep?.progress === "number"
? `${Math.min(Math.max(latestActiveStep.progress, 0), 100)}%`
: latestActiveStep?.status === "running"
? "50%"
: "100%";
return (
<div
className="modal-overlay"
onClick={closeOnOverlayClick ? handleClose : undefined}
>
<div
className="modal animate-slide-in"
role="dialog"
aria-modal="true"
aria-labelledby="process-logs-modal-title"
style={{
width: "min(100%, 620px)",
maxWidth: 620,
padding: 0,
overflow: "hidden",
maxHeight: "92vh",
display: "flex",
flexDirection: "column",
borderRadius: 18,
}}
onClick={(event) => event.stopPropagation()}
>
<div
style={{
display: "flex",
alignItems: "flex-start",
justifyContent: "space-between",
gap: 14,
padding: "22px 26px 14px",
}}
>
<div style={{ display: "flex", gap: 12, minWidth: 0 }}>
<div
aria-hidden="true"
style={{
width: 26,
height: 26,
borderRadius: 999,
flex: "0 0 auto",
border: "1px solid rgba(22,163,74,0.65)",
background: "rgba(34,197,94,0.24)",
}}
/>
<div style={{ minWidth: 0 }}>
<h3
id="process-logs-modal-title"
style={{
color: "var(--text-primary)",
fontSize: 16,
fontWeight: 750,
lineHeight: 1.2,
overflowWrap: "anywhere",
}}
>
{title}
</h3>
<p
style={{
color: "var(--text-muted)",
fontSize: 12,
lineHeight: 1.55,
marginTop: 4,
overflowWrap: "anywhere",
}}
>
{description}
</p>
</div>
</div>
<button
type="button"
className="btn btn-ghost"
onClick={handleClose}
aria-label="Close process logs modal"
style={{
width: 25,
height: 25,
padding: 0,
flex: "0 0 auto",
justifyContent: "center",
color: "var(--text-muted)",
}}
>
<X size={21} />
</button>
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
gap: 12,
padding: "8px 25px 18px",
}}
>
<button
type="button"
className="btn btn-ghost"
onClick={() => setSelectedTab("timeline")}
style={{
minHeight: 34,
justifyContent: "center",
border:
activeTab === "timeline"
? "1px solid rgba(59,130,246,0.38)"
: "1px solid var(--border)",
background:
activeTab === "timeline"
? "rgba(59,130,246,0.1)"
: "var(--bg-card)",
color: activeTab === "timeline" ? "#3b82f6" : "var(--text-muted)",
fontWeight: 700,
}}
>
<ScrollText size={14} /> Timeline
</button>
<button
type="button"
className="btn btn-ghost"
onClick={() => setSelectedTab("terminal")}
style={{
minHeight: 34,
justifyContent: "center",
border:
activeTab === "terminal"
? "1px solid rgba(59,130,246,0.38)"
: "1px solid var(--border)",
background:
activeTab === "terminal"
? "rgba(59,130,246,0.1)"
: "var(--bg-card)",
color: activeTab === "terminal" ? "#3b82f6" : "var(--text-muted)",
fontWeight: 700,
}}
>
<Terminal size={14} /> Terminal Logs
</button>
</div>
<div style={{ flex: 1, overflow: "auto", padding: "0 28px 26px" }}>
{activeTab === "timeline" ? (
<div style={{ display: "grid", gap: 22 }}>
<div
style={{
width: 144,
height: 115,
border: "1px solid var(--border)",
borderRadius: 18,
margin: "0 auto",
display: "grid",
placeItems: "center",
color: "var(--text-muted)",
background: "var(--bg-input)",
overflow: "hidden",
}}
>
{imageSlot ??
(imageUrl ? (
<Image
src={imageUrl}
alt={imageAlt}
width={144}
height={115}
unoptimized
style={{
width: "100%",
height: "100%",
objectFit: "cover",
}}
/>
) : (
<ImageIcon size={30} />
))}
</div>
<div style={{ display: "grid", gap: 14 }}>
{timelineLogs.map((step) => {
const status = step.status ?? "pending";
const tone = statusTone[status];
const stepProgress = formatProgress(step.progress);
return (
<div
key={step.id}
style={{
display: "grid",
gridTemplateColumns: "minmax(0, 1fr) auto",
alignItems: "center",
gap: 14,
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: 10,
minWidth: 0,
}}
>
<span
aria-hidden="true"
style={{
width: 26,
height: 26,
borderRadius: 999,
display: "grid",
placeItems: "center",
flex: "0 0 auto",
color: tone.color,
border: `1px solid ${tone.border}`,
background: tone.background,
}}
>
{tone.icon}
</span>
<span style={{ minWidth: 0 }}>
<strong
style={{
display: "block",
color: tone.color,
fontSize: 15,
fontWeight: 750,
lineHeight: 1.35,
overflowWrap: "anywhere",
}}
>
{step.label}
</strong>
{step.description ? (
<span
style={{
display: "block",
color: "var(--text-muted)",
fontSize: 12,
lineHeight: 1.45,
marginTop: 2,
overflowWrap: "anywhere",
}}
>
{step.description}
</span>
) : null}
</span>
</div>
{stepProgress ? (
<strong
style={{
color: tone.color,
fontSize: 14,
fontWeight: 750,
whiteSpace: "nowrap",
}}
>
{stepProgress}
</strong>
) : null}
</div>
);
})}
</div>
{progressLabel ? (
<div
aria-label={`Current progress ${progressLabel}`}
style={{
height: 8,
borderRadius: 999,
background: "var(--bg-input)",
overflow: "hidden",
}}
>
<div
style={{
width: progressBarWidth,
height: "100%",
borderRadius: 999,
background: "#16a34a",
}}
/>
</div>
) : null}
</div>
) : (
<pre
ref={terminalViewportRef}
className="terminal-text"
style={{
minHeight: 360,
maxHeight: "56vh",
overflow: "auto",
margin: 0,
padding: 18,
borderRadius: 12,
border: "1px solid rgba(148,163,184,0.2)",
background: "#0d1117",
color: "#e6edf3",
lineHeight: 1.65,
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}
>
{sanitizedTerminalLogs}
</pre>
)}
</div>
</div>
</div>
);
}
+43
View File
@@ -0,0 +1,43 @@
interface ReadOnlyAccessNoticeProps {
title?: string;
message?: string;
}
export default function ReadOnlyAccessNotice({
title = "Viewer Mode Enabled",
message = "Your role is read-only. Buttons and forms that change data are disabled on this page.",
}: ReadOnlyAccessNoticeProps) {
return (
<div
className="card"
style={{
padding: "12px 16px",
marginBottom: 16,
display: "flex",
alignItems: "center",
gap: 10,
color: "#f59e0b",
border: "1px solid rgba(245,158,11,0.28)",
background: "rgba(245,158,11,0.08)",
}}
>
<div
style={{
width: 8,
height: 8,
borderRadius: "50%",
background: "currentColor",
flexShrink: 0,
}}
/>
<div>
<p style={{ fontSize: 12, fontWeight: 700, color: "#fbbf24" }}>
{title}
</p>
<p style={{ fontSize: 12, color: "var(--text-secondary)" }}>
{message}
</p>
</div>
</div>
);
}
+187
View File
@@ -0,0 +1,187 @@
"use client";
import { useEffect, useRef, type ReactNode } from "react";
const WRITE_ACTION_PATTERN =
/(add|create|deploy|save|delete|remove|edit|issue|renew|revoke|restart|stop|start|install|uninstall|reinstall|rebuild|reset|prune|sync|check all|toggle|generate|verify|test|restore|backup|reboot|terminal|choose domain)/i;
function markButtonLikeElement(element: HTMLElement) {
if (element.dataset.rbacReadonlyDisabled === "true") {
return;
}
element.dataset.rbacReadonlyDisabled = "true";
if (
element instanceof HTMLButtonElement ||
element instanceof HTMLInputElement ||
element instanceof HTMLSelectElement ||
element instanceof HTMLTextAreaElement
) {
element.dataset.rbacReadonlyOriginalDisabled = String(element.disabled);
element.disabled = true;
return;
}
element.dataset.rbacReadonlyOriginalAriaDisabled =
element.getAttribute("aria-disabled") ?? "";
element.dataset.rbacReadonlyOriginalTabIndex = String(element.tabIndex);
element.dataset.rbacReadonlyOriginalPointerEvents =
element.style.pointerEvents;
element.dataset.rbacReadonlyOriginalOpacity = element.style.opacity;
element.setAttribute("aria-disabled", "true");
element.tabIndex = -1;
element.style.pointerEvents = "none";
element.style.opacity = "0.55";
}
function restoreMarkedElement(element: HTMLElement) {
if (element.dataset.rbacReadonlyDisabled !== "true") {
return;
}
delete element.dataset.rbacReadonlyDisabled;
if (
element instanceof HTMLButtonElement ||
element instanceof HTMLInputElement ||
element instanceof HTMLSelectElement ||
element instanceof HTMLTextAreaElement
) {
element.disabled = element.dataset.rbacReadonlyOriginalDisabled === "true";
delete element.dataset.rbacReadonlyOriginalDisabled;
return;
}
const previousAriaDisabled = element.dataset.rbacReadonlyOriginalAriaDisabled;
if (previousAriaDisabled) {
element.setAttribute("aria-disabled", previousAriaDisabled);
} else {
element.removeAttribute("aria-disabled");
}
const previousTabIndex = Number(element.dataset.rbacReadonlyOriginalTabIndex);
if (!Number.isNaN(previousTabIndex)) {
element.tabIndex = previousTabIndex;
}
element.style.pointerEvents =
element.dataset.rbacReadonlyOriginalPointerEvents ?? "";
element.style.opacity = element.dataset.rbacReadonlyOriginalOpacity ?? "";
delete element.dataset.rbacReadonlyOriginalAriaDisabled;
delete element.dataset.rbacReadonlyOriginalTabIndex;
delete element.dataset.rbacReadonlyOriginalPointerEvents;
delete element.dataset.rbacReadonlyOriginalOpacity;
}
function getElementActionText(element: HTMLElement) {
return [
element.getAttribute("title") ?? "",
element.getAttribute("aria-label") ?? "",
element instanceof HTMLInputElement ? element.value : "",
element.textContent ?? "",
]
.join(" ")
.replace(/\s+/g, " ")
.trim();
}
function shouldDisableElement(element: HTMLElement) {
if (element.dataset.rbacAllowReadonly === "true") {
return false;
}
if (element instanceof HTMLButtonElement) {
if (element.type === "submit") {
return true;
}
const classNames = element.className;
if (
typeof classNames === "string" &&
/(btn-primary|btn-danger|btn-success)/.test(classNames)
) {
return true;
}
}
return WRITE_ACTION_PATTERN.test(getElementActionText(element));
}
function disableWriteControls(scope: HTMLElement) {
const writeControls = scope.querySelectorAll<HTMLElement>(
"button, a.btn, input[type='submit'], input[type='button']",
);
const formsToLock = new Set<HTMLFormElement>();
for (const control of writeControls) {
if (!shouldDisableElement(control)) {
continue;
}
markButtonLikeElement(control);
const parentForm = control.closest("form");
if (parentForm) {
formsToLock.add(parentForm);
}
}
for (const form of formsToLock) {
const editableFields = form.querySelectorAll<HTMLElement>(
"input:not([type='hidden']):not([type='button']):not([type='submit']), select, textarea",
);
editableFields.forEach(markButtonLikeElement);
}
}
interface ReadOnlyActionScopeProps {
enabled: boolean;
children: ReactNode;
}
export default function ReadOnlyActionScope({
enabled,
children,
}: ReadOnlyActionScopeProps) {
const scopeRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const scope = scopeRef.current;
if (!scope) {
return;
}
const clearScope = () => {
scope
.querySelectorAll<HTMLElement>("[data-rbac-readonly-disabled='true']")
.forEach(restoreMarkedElement);
};
if (!enabled) {
clearScope();
return;
}
disableWriteControls(scope);
const observer = new MutationObserver(() => {
disableWriteControls(scope);
});
observer.observe(scope, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ["class", "title", "aria-label", "disabled"],
});
return () => {
observer.disconnect();
clearScope();
};
}, [enabled]);
return <div ref={scopeRef}>{children}</div>;
}
+60
View File
@@ -0,0 +1,60 @@
import { Search } from "lucide-react";
import type { ChangeEventHandler, CSSProperties } from "react";
type SearchFieldProps = {
placeholder: string;
value: string;
onChange: ChangeEventHandler<HTMLInputElement>;
containerStyle?: CSSProperties;
inputStyle?: CSSProperties;
autoComplete?: string;
};
export default function SearchField({
placeholder,
value,
onChange,
containerStyle,
inputStyle,
autoComplete = "off",
}: SearchFieldProps) {
return (
<div
style={{
position: "relative",
flex: "1 1 320px",
minWidth: 220,
...containerStyle,
}}
>
<Search
size={13}
style={{
position: "absolute",
left: 10,
top: "50%",
transform: "translateY(-50%)",
color: "var(--text-muted)",
pointerEvents: "none",
}}
/>
<input
className="input"
type="text"
name="panel-search-field"
autoComplete={autoComplete}
autoCorrect="off"
autoCapitalize="none"
spellCheck={false}
placeholder={placeholder}
value={value}
onChange={onChange}
style={{
paddingLeft: 30,
width: "100%",
...inputStyle,
}}
/>
</div>
);
}
+329
View File
@@ -0,0 +1,329 @@
"use client";
import { Check, ChevronDown, Search } from "lucide-react";
import {
useEffect,
useMemo,
useRef,
useState,
type CSSProperties,
} from "react";
import { createPortal } from "react-dom";
export type SearchableSelectOption = {
value: string;
label: string;
keywords?: string;
description?: string;
};
type SearchableSelectProps = {
value: string;
options: SearchableSelectOption[];
onChange: (value: string) => void;
placeholder: string;
searchPlaceholder: string;
emptyText: string;
disabled?: boolean;
style?: CSSProperties;
showSearch?: boolean;
};
// Constants for menu sizing and option row height to ensure consistent layout
const VISIBLE_OPTION_COUNT = 5;
const OPTION_ROW_HEIGHT = 38;
export default function SearchableSelect({
value,
options,
onChange,
placeholder,
searchPlaceholder,
emptyText,
disabled = false,
style,
showSearch = true,
}: SearchableSelectProps) {
const wrapperRef = useRef<HTMLDivElement | null>(null);
const triggerRef = useRef<HTMLButtonElement | null>(null);
const menuRef = useRef<HTMLDivElement | null>(null);
const searchInputRef = useRef<HTMLInputElement | null>(null);
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [menuStyle, setMenuStyle] = useState<CSSProperties | null>(null);
const selected = useMemo(
() => options.find((option) => option.value === value) ?? null,
[options, value],
);
const filteredOptions = useMemo(() => {
if (!showSearch) return options;
const normalizedQuery = query.trim().toLowerCase();
if (!normalizedQuery) return options;
return options.filter((option) => {
const haystack =
`${option.label} ${option.keywords ?? ""} ${option.description ?? ""}`
.trim()
.toLowerCase();
return haystack.includes(normalizedQuery);
});
}, [options, query, showSearch]);
useEffect(() => {
if (!open) {
setQuery("");
return;
}
if (!showSearch) return;
const frame = window.requestAnimationFrame(() => {
searchInputRef.current?.focus();
});
return () => window.cancelAnimationFrame(frame);
}, [open, showSearch]);
useEffect(() => {
const handlePointerDown = (event: MouseEvent) => {
const target = event.target as Node;
if (
!wrapperRef.current?.contains(target) &&
!menuRef.current?.contains(target)
) {
setOpen(false);
}
};
document.addEventListener("mousedown", handlePointerDown);
return () => document.removeEventListener("mousedown", handlePointerDown);
}, []);
useEffect(() => {
if (!open) {
setMenuStyle(null);
return;
}
const syncMenuPosition = () => {
const rect = triggerRef.current?.getBoundingClientRect();
if (!rect) return;
setMenuStyle({
position: "fixed",
top: rect.bottom + 6,
left: rect.left,
width: rect.width,
zIndex: 2100,
});
};
syncMenuPosition();
window.addEventListener("resize", syncMenuPosition);
window.addEventListener("scroll", syncMenuPosition, true);
return () => {
window.removeEventListener("resize", syncMenuPosition);
window.removeEventListener("scroll", syncMenuPosition, true);
};
}, [open]);
return (
<div ref={wrapperRef} style={{ position: "relative", ...style }}>
<button
ref={triggerRef}
type="button"
className="input"
disabled={disabled}
onClick={() => {
if (disabled) return;
setOpen((current) => !current);
}}
style={{
width: "100%",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 10,
cursor: disabled ? "not-allowed" : "pointer",
textAlign: "left",
opacity: disabled ? 0.7 : 1,
fontWeight: 400,
}}
>
<span
style={{
color: selected ? "var(--text-primary)" : "var(--text-muted)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{selected?.label ?? placeholder}
</span>
<ChevronDown size={14} style={{ color: "var(--text-muted)" }} />
</button>
{open && menuStyle
? createPortal(
<div
ref={menuRef}
style={{
...menuStyle,
borderRadius: 6,
border: "1px solid var(--border)",
background: "var(--bg-card)",
boxShadow: "0 18px 32px rgba(15, 23, 42, 0.18)",
overflow: "hidden",
}}
>
{showSearch ? (
<div
style={{
padding: 6,
borderBottom: "1px solid var(--border)",
background: "var(--bg-card)",
}}
>
<div style={{ position: "relative" }}>
<Search
size={13}
style={{
position: "absolute",
left: 10,
top: "50%",
transform: "translateY(-50%)",
color: "var(--text-muted)",
pointerEvents: "none",
}}
/>
<input
ref={searchInputRef}
className="input"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={searchPlaceholder}
autoComplete="off"
autoCorrect="off"
autoCapitalize="none"
spellCheck={false}
style={{
width: "100%",
paddingLeft: 30,
minHeight: 34,
background: "var(--bg-input)",
borderColor: "var(--border)",
borderRadius: 4,
boxShadow: "none",
}}
/>
</div>
</div>
) : null}
<div
style={{
maxHeight: OPTION_ROW_HEIGHT * VISIBLE_OPTION_COUNT,
overflowY: "auto",
padding: 0,
background: "var(--bg-card)",
}}
>
{filteredOptions.length === 0 ? (
<div
style={{
padding: "10px 12px",
minHeight: OPTION_ROW_HEIGHT,
fontSize: 12,
color: "var(--text-muted)",
}}
>
{emptyText}
</div>
) : (
filteredOptions.map((option) => {
const active = option.value === value;
return (
<button
key={option.value}
type="button"
onClick={() => {
onChange(option.value);
setOpen(false);
}}
style={{
width: "100%",
border: "none",
borderBottom: "1px solid var(--border)",
background: active
? "var(--bg-card-hover)"
: "transparent",
color: "var(--text-primary)",
minHeight: OPTION_ROW_HEIGHT,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 10,
textAlign: "left",
borderRadius: 0,
padding: "9px 12px",
cursor: "pointer",
}}
>
<span style={{ minWidth: 0 }}>
<span
style={{
display: "flex",
alignItems: "center",
gap: 8,
fontSize: 13,
lineHeight: 1.35,
overflow: "hidden",
}}
>
<span
style={{
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
fontWeight: 600,
}}
>
{option.label}
</span>
{showSearch && option.description ? (
<span
style={{
flexShrink: 0,
color: "var(--text-secondary)",
fontSize: 11,
fontWeight: 500,
}}
>
{option.description}
</span>
) : null}
</span>
</span>
{active ? (
<Check
size={14}
style={{ color: "var(--text-primary)" }}
/>
) : null}
</button>
);
})
)}
</div>
</div>,
document.body,
)
: null}
</div>
);
}
+464
View File
@@ -0,0 +1,464 @@
"use client";
import Image from "next/image";
import { useEffect, useState, type MouseEvent } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { auth, clearToken, redirectToLogin } from "@/lib/api";
import { useCurrentUser } from "@/lib/auth-state";
import OrganizationSwitcher from "@/components/OrganizationSwitcher";
import { navigation } from "@/lib/navigation";
import { addPreferencesListener, getStoredPanelName } from "@/lib/preferences";
import { formatRoleLabel, hasMinimumRole } from "@/lib/permissions";
import { LogOut, ChevronLeft, ChevronRight, X } from "lucide-react";
interface SidebarProps {
/** Desktop: whether sidebar is in collapsed (icon-only) state */
collapsed: boolean;
/** Desktop: called when user clicks the collapse toggle */
onToggle: () => void;
/** Mobile: whether screen is narrow */
isMobile: boolean;
/** Mobile: whether the drawer is open */
mobileOpen: boolean;
/** Mobile: called when user closes the drawer */
onMobileClose: () => void;
}
export default function Sidebar({
collapsed,
onToggle,
isMobile,
mobileOpen,
onMobileClose,
}: SidebarProps) {
const pathname = usePathname();
const currentUser = useCurrentUser();
const [loggingOut, setLoggingOut] = useState(false);
const [pendingHref, setPendingHref] = useState<string | null>(null);
const [panelName, setPanelName] = useState("DOKTAINER");
const visibleNavigation = navigation
.map((section) => ({
...section,
items: section.items.filter((item) =>
hasMinimumRole(currentUser?.role, item.minRole),
),
}))
.filter((section) => section.items.length > 0);
const handleLogout = async () => {
if (loggingOut) return;
setLoggingOut(true);
try {
await auth.logout();
} catch {
// Best effort server-side logout; local session is cleared regardless.
} finally {
clearToken();
onMobileClose();
redirectToLogin();
}
};
useEffect(() => {
const syncPanelName = () => {
setPanelName(getStoredPanelName().toUpperCase());
};
syncPanelName();
return addPreferencesListener(syncPanelName);
}, []);
useEffect(() => {
const timeoutId = window.setTimeout(() => {
setPendingHref(null);
}, 0);
return () => window.clearTimeout(timeoutId);
}, [pathname]);
const handleNavigate = (
event: MouseEvent<HTMLAnchorElement>,
href: string,
) => {
if (
event.defaultPrevented ||
event.button !== 0 ||
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
event.altKey
) {
return;
}
if (href === pathname) {
onMobileClose();
return;
}
setPendingHref(href);
onMobileClose();
};
// On mobile the sidebar is always full-width (never icon-only)
const isCollapsed = !isMobile && collapsed;
const sidebar = (
<aside
style={{
position: "relative",
width: isMobile ? "240px" : isCollapsed ? "60px" : "220px",
height: "100vh",
background: "var(--bg-sidebar)",
borderRight: "1px solid var(--border)",
display: "flex",
flexDirection: "column",
transition: isMobile ? "none" : "width 0.25s ease",
flexShrink: 0,
}}
>
{/* ── Logo + collapse/close button ──────────────────────── */}
<div
style={{
padding: isCollapsed ? "0 12px" : "0 12px 0 16px",
borderBottom: "1px solid var(--border)",
display: "flex",
alignItems: "center",
gap: 8,
height: 64,
flexShrink: 0,
}}
>
{/* App icon */}
<div
style={{
width: 32,
height: 32,
borderRadius: 8,
background: "linear-gradient(135deg, #3b82f6, #8b5cf6)",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
boxShadow: "0 0 12px rgba(59, 130, 246, 0.4)",
}}
>
{/* <Cpu size={16} color="white" /> */}
<Image
src="/assets/images/favicon.png"
alt="Logo"
width={30}
height={30}
/>
</div>
{/* Title — hidden when collapsed on desktop */}
{!isCollapsed && (
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: 13,
fontWeight: 700,
color: "var(--text-primary)",
}}
>
{panelName}
</div>
<div
style={{
fontSize: 10,
color: "var(--text-muted)",
letterSpacing: "0.05em",
}}
>
{`${process.env.NEXT_PUBLIC_VERSION || "unknown"} | ${process.env.NEXT_PUBLIC_BATCH || "dev"}`}
</div>
</div>
)}
{/* Collapse toggle (desktop) or close button (mobile) */}
<button
onClick={isMobile ? onMobileClose : onToggle}
title={
isMobile
? "Close menu"
: isCollapsed
? "Expand sidebar"
: "Collapse sidebar"
}
style={{
position: isMobile ? "static" : "absolute",
right: isMobile ? undefined : -14,
top: isMobile ? undefined : 18,
zIndex: 50,
flexShrink: 0,
width: 28,
height: 28,
borderRadius: "50%",
background: "var(--bg-sidebar)",
border: "1px solid var(--border)",
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
color: "var(--text-muted)",
transition: "background 0.15s, color 0.15s, border-color 0.15s",
marginLeft: isMobile ? "auto" : undefined,
boxShadow: isMobile ? "none" : "0 2px 8px rgba(0,0,0,0.2)",
}}
onMouseEnter={(e) => {
const btn = e.currentTarget;
btn.style.background = "rgba(59,130,246,0.12)";
btn.style.borderColor = "rgba(59,130,246,0.4)";
btn.style.color = "#3b82f6";
}}
onMouseLeave={(e) => {
const btn = e.currentTarget;
btn.style.background = "var(--bg-sidebar)";
btn.style.borderColor = "var(--border)";
btn.style.color = "var(--text-muted)";
}}
>
{isMobile ? (
<X size={13} />
) : isCollapsed ? (
<ChevronRight size={13} />
) : (
<ChevronLeft size={13} />
)}
</button>
</div>
<OrganizationSwitcher
collapsed={isCollapsed}
canManage={currentUser?.role === "SUPER_ADMIN"}
/>
{/* ── Navigation ──────────────────────────────────────────── */}
<nav
style={{
flex: 1,
overflowY: "auto",
overflowX: "hidden",
padding: "12px 8px",
display: "flex",
flexDirection: "column",
gap: 2,
}}
>
{visibleNavigation.map((section) => (
<div key={section.label} style={{ marginBottom: 4 }}>
{!isCollapsed && (
<div
style={{
fontSize: 10,
fontWeight: 600,
letterSpacing: "0.08em",
color: "var(--text-muted)",
padding: "8px 12px 4px",
textTransform: "uppercase",
}}
>
{section.label}
</div>
)}
{isCollapsed && <div style={{ height: 6 }} />}
{section.items.map((item) => {
const Icon = item.icon;
const isActive =
pathname === item.href || pendingHref === item.href;
const isNavigatingToItem =
pendingHref === item.href && pathname !== item.href;
return (
<Link
key={item.href}
href={item.href}
className="nav-item"
data-active={isActive}
aria-current={pathname === item.href ? "page" : undefined}
aria-busy={isNavigatingToItem || undefined}
onClick={(event) => handleNavigate(event, item.href)}
style={{
background: isActive
? "rgba(59, 130, 246, 0.12)"
: "transparent",
color: isActive ? "#3b82f6" : "var(--text-secondary)",
borderLeft: isActive
? "2px solid #3b82f6"
: "2px solid transparent",
justifyContent: isCollapsed ? "center" : "flex-start",
padding: isCollapsed
? "9px 0"
: isActive
? "8px 12px 8px 10px"
: "8px 12px",
}}
title={isCollapsed ? item.label : undefined}
>
<Icon
size={15}
style={{
flexShrink: 0,
color: isActive ? "#3b82f6" : "var(--text-muted)",
}}
/>
{!isCollapsed && (
<span style={{ fontSize: 13, fontWeight: 500 }}>
{item.label}
</span>
)}
{/* Navigating indicator */}
{isNavigatingToItem && (
<span
className="animate-pulse-dot"
aria-hidden="true"
style={{
width: 6,
height: 6,
marginLeft: isCollapsed ? 0 : "auto",
borderRadius: "50%",
background: "#3b82f6",
boxShadow: "0 0 10px rgba(59, 130, 246, 0.55)",
}}
/>
)}
{!isCollapsed && item.badge && (
<span
style={{
marginLeft: isNavigatingToItem ? 0 : "auto",
background: "rgba(59, 130, 246, 0.2)",
color: "#3b82f6",
fontSize: 10,
fontWeight: 600,
padding: "1px 6px",
borderRadius: 10,
}}
>
{item.badge}
</span>
)}
</Link>
);
})}
</div>
))}
</nav>
{/* ── User footer ─────────────────────────────────────────── */}
<div
style={{
padding: isCollapsed ? "12px 8px" : "12px 16px",
borderTop: "1px solid var(--border)",
display: "flex",
alignItems: "center",
gap: 10,
}}
>
<div
style={{
width: 32,
height: 32,
borderRadius: 8,
background: "linear-gradient(135deg, #3b82f6, #8b5cf6)",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
fontSize: 12,
fontWeight: 700,
color: "white",
}}
>
{(
currentUser?.name?.charAt(0) ||
currentUser?.email?.charAt(0) ||
"U"
).toUpperCase()}
</div>
{!isCollapsed && (
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: 12,
fontWeight: 600,
color: "var(--text-primary)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{currentUser?.name || currentUser?.email || "Authenticated User"}
</div>
<div style={{ fontSize: 11, color: "var(--text-muted)" }}>
{formatRoleLabel(currentUser?.role)}
</div>
</div>
)}
<button
onClick={() => void handleLogout()}
disabled={loggingOut}
style={{
marginLeft: isCollapsed ? "auto" : 0,
background: "transparent",
border: "none",
color: loggingOut ? "#3b82f6" : "var(--text-muted)",
cursor: loggingOut ? "default" : "pointer",
padding: 4,
borderRadius: 6,
opacity: loggingOut ? 0.8 : 1,
}}
title={loggingOut ? "Logging out..." : "Logout"}
>
<LogOut size={14} />
</button>
</div>
</aside>
);
// ── Mobile: full-screen overlay with slide-in drawer ──────────
if (isMobile) {
return (
<div
style={{
position: "fixed",
inset: 0,
zIndex: 40,
pointerEvents: mobileOpen ? "auto" : "none",
}}
>
{/* Backdrop */}
<div
onClick={onMobileClose}
style={{
position: "absolute",
inset: 0,
background: "rgba(0,0,0,0.55)",
opacity: mobileOpen ? 1 : 0,
transition: "opacity 0.25s ease",
}}
/>
{/* Drawer panel */}
<div
style={{
position: "absolute",
top: 0,
left: 0,
height: "100%",
transform: mobileOpen ? "translateX(0)" : "translateX(-100%)",
transition: "transform 0.25s ease",
}}
>
{sidebar}
</div>
</div>
);
}
// ── Desktop: plain inline flex item ───────────────────────────
return sidebar;
}
+63
View File
@@ -0,0 +1,63 @@
"use client";
interface TablePaginationProps {
currentPage: number;
totalPages: number;
totalItems: number;
startItem: number;
endItem: number;
itemLabel?: string;
onPageChange: (page: number) => void;
}
export default function TablePagination({
currentPage,
totalPages,
totalItems,
startItem,
endItem,
itemLabel = "items",
onPageChange,
}: TablePaginationProps) {
if (totalItems === 0) return null;
return (
<div
style={{
padding: "10px 16px",
borderTop: "1px solid var(--border)",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 12,
flexWrap: "wrap",
}}
>
<span style={{ fontSize: 12, color: "var(--text-muted)" }}>
Showing {startItem}-{endItem} of {totalItems} {itemLabel}
</span>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<button
className="btn btn-ghost"
style={{ fontSize: 12, padding: "5px 10px" }}
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage <= 1}
>
Prev
</button>
<span style={{ fontSize: 12, color: "var(--text-secondary)" }}>
Page {currentPage} / {totalPages}
</span>
<button
className="btn btn-ghost"
style={{ fontSize: 12, padding: "5px 10px" }}
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage >= totalPages}
>
Next
</button>
</div>
</div>
);
}
+40
View File
@@ -0,0 +1,40 @@
"use client";
import { useEffect } from "react";
import {
addPreferencesListener,
applyTheme,
getStoredTheme,
initializeSessionTimeoutWatcher,
} from "@/lib/preferences";
export default function ThemeController() {
useEffect(() => {
const syncTheme = () => applyTheme(getStoredTheme());
syncTheme();
const media = window.matchMedia("(prefers-color-scheme: light)");
const onThemeMediaChange = () => {
if (getStoredTheme() === "system") {
syncTheme();
}
};
media.addEventListener("change", onThemeMediaChange);
window.addEventListener("pageshow", syncTheme);
document.addEventListener("visibilitychange", syncTheme);
const removePreferencesListener = addPreferencesListener(syncTheme);
const stopSessionWatcher = initializeSessionTimeoutWatcher();
return () => {
media.removeEventListener("change", onThemeMediaChange);
window.removeEventListener("pageshow", syncTheme);
document.removeEventListener("visibilitychange", syncTheme);
removePreferencesListener();
stopSessionWatcher();
};
}, []);
return null;
}
+177
View File
@@ -0,0 +1,177 @@
"use client";
import { AlertTriangle, CheckCircle, Info, X, XCircle } from "lucide-react";
export type ToastTone = "success" | "error" | "warning" | "info";
interface ToastProps {
tone: ToastTone;
title?: string;
message: string;
onClose?: () => void;
duration?: number;
showProgress?: boolean;
}
const toneConfig: Record<
ToastTone,
{
title: string;
icon: typeof CheckCircle;
border: string;
background: string;
titleColor: string;
messageColor: string;
closeColor: string;
iconColor: string;
}
> = {
success: {
title: "Success",
icon: CheckCircle,
border: "1px solid rgba(16,185,129,0.35)",
background:
"linear-gradient(135deg, rgba(16,185,129,0.16), rgba(15,23,42,0.94))",
titleColor: "#d1fae5",
messageColor: "rgba(209,250,229,0.9)",
closeColor: "rgba(209,250,229,0.75)",
iconColor: "#10b981",
},
error: {
title: "Error",
icon: XCircle,
border: "1px solid rgba(239,68,68,0.35)",
background:
"linear-gradient(135deg, rgba(239,68,68,0.18), rgba(15,23,42,0.94))",
titleColor: "#fee2e2",
messageColor: "rgba(254,226,226,0.9)",
closeColor: "rgba(254,226,226,0.75)",
iconColor: "#ef4444",
},
warning: {
title: "Warning",
icon: AlertTriangle,
border: "1px solid rgba(245,158,11,0.35)",
background:
"linear-gradient(135deg, rgba(245,158,11,0.18), rgba(15,23,42,0.94))",
titleColor: "#fef3c7",
messageColor: "rgba(254,243,199,0.9)",
closeColor: "rgba(254,243,199,0.75)",
iconColor: "#f59e0b",
},
info: {
title: "Info",
icon: Info,
border: "1px solid rgba(59,130,246,0.35)",
background:
"linear-gradient(135deg, rgba(59,130,246,0.18), rgba(15,23,42,0.94))",
titleColor: "#dbeafe",
messageColor: "rgba(219,234,254,0.9)",
closeColor: "rgba(219,234,254,0.75)",
iconColor: "#3b82f6",
},
};
export default function Toast({
tone,
title,
message,
onClose,
duration,
showProgress = false,
}: ToastProps) {
const config = toneConfig[tone];
const Icon = config.icon;
return (
<>
<div
className="card animate-slide-in"
style={{
padding: "12px 14px",
display: "flex",
alignItems: "flex-start",
gap: 10,
border: config.border,
background: config.background,
boxShadow: "0 18px 40px rgba(2,6,23,0.35)",
overflow: "hidden",
position: "relative",
}}
>
<Icon size={18} style={{ color: config.iconColor, flexShrink: 0 }} />
<div style={{ flex: 1, minWidth: 0 }}>
<p
style={{
color: config.titleColor,
fontSize: 13,
fontWeight: 700,
marginBottom: 2,
}}
>
{title ?? config.title}
</p>
<p
style={{
color: config.messageColor,
fontSize: 12,
lineHeight: 1.45,
}}
>
{message}
</p>
</div>
{onClose && (
<button
type="button"
onClick={onClose}
style={{
background: "none",
border: "none",
cursor: "pointer",
color: config.closeColor,
padding: 0,
flexShrink: 0,
}}
>
<X size={16} />
</button>
)}
{showProgress && duration && (
<div
aria-hidden="true"
style={{
position: "absolute",
left: 0,
right: 0,
bottom: 0,
height: 3,
background: "rgba(255,255,255,0.08)",
}}
>
<div
style={{
height: "100%",
width: "100%",
background: config.iconColor,
transformOrigin: "left center",
animation: `toast-progress-shrink ${duration}ms linear forwards`,
}}
/>
</div>
)}
</div>
<style jsx>{`
@keyframes toast-progress-shrink {
from {
transform: scaleX(1);
}
to {
transform: scaleX(0);
}
}
`}</style>
</>
);
}
+75
View File
@@ -0,0 +1,75 @@
"use client";
import Toast from "@/components/Toast";
import type { ToastItem } from "@/lib/use-toast-manager";
export type ToastViewportPosition =
| "top-right"
| "top-left"
| "bottom-right"
| "bottom-left";
interface ToastViewportProps {
toasts: ToastItem[];
onClose: (id: string) => void;
position?: ToastViewportPosition;
}
const viewportPositionStyle: Record<
ToastViewportPosition,
React.CSSProperties
> = {
"top-right": {
top: 80,
right: 20,
},
"top-left": {
top: 80,
left: 20,
},
"bottom-right": {
bottom: 20,
right: 20,
},
"bottom-left": {
bottom: 20,
left: 20,
},
};
export default function ToastViewport({
toasts,
onClose,
position = "top-right",
}: ToastViewportProps) {
if (toasts.length === 0) return null;
return (
<div
style={{
position: "fixed",
zIndex: 1100,
width: "calc(100vw - 32px)",
maxWidth: 360,
display: "flex",
flexDirection: "column",
gap: 10,
pointerEvents: "none",
...viewportPositionStyle[position],
}}
>
{toasts.map((toast) => (
<div key={toast.id} style={{ pointerEvents: "auto" }}>
<Toast
tone={toast.tone}
title={toast.title}
message={toast.message}
duration={toast.duration}
showProgress={toast.showProgress}
onClose={() => onClose(toast.id)}
/>
</div>
))}
</div>
);
}
File diff suppressed because it is too large. Load diff
+1
View File
@@ -0,0 +1 @@
for Shared UI components