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

@@ -0,0 +1,326 @@
import TablePagination from "@/components/TablePagination";
import { Clock, Edit3, Loader2, Lock, Shield, Trash2 } from "lucide-react";
import type { UserRecord } from "@/lib/api";
import { getRoleMeta } from "@/app/users/components/user-role-config";
import {
formatRelativeDate,
getInitials,
getServerAccessSummary,
} from "@/app/users/components/user-utils";
interface ActiveUsersPanelProps {
loading: boolean;
items: UserRecord[];
currentUserId: string | undefined;
canManageAccess: boolean;
canManageRoles: boolean;
mutatingKey: string | null;
currentPage: number;
totalPages: number;
totalItems: number;
startItem: number;
endItem: number;
onPageChange: (page: number) => void;
onEditAccess: (user: UserRecord) => void;
onEditRole: (user: UserRecord) => void;
onDelete: (user: UserRecord) => void | Promise<void>;
}
export default function ActiveUsersPanel({
loading,
items,
currentUserId,
canManageAccess,
canManageRoles,
mutatingKey,
currentPage,
totalPages,
totalItems,
startItem,
endItem,
onPageChange,
onEditAccess,
onEditRole,
onDelete,
}: ActiveUsersPanelProps) {
return (
<div className="card" style={{ overflow: "hidden" }}>
{loading ? (
<div
style={{
padding: 32,
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 10,
color: "var(--text-muted)",
fontSize: 13,
}}
>
<Loader2 size={16} className="animate-spin" />
Loading users...
</div>
) : items.length === 0 ? (
<div
style={{
padding: 32,
textAlign: "center",
color: "var(--text-muted)",
fontSize: 13,
}}
>
No active users found.
</div>
) : (
<>
<table className="data-table">
<thead>
<tr>
<th>User</th>
<th>Role</th>
<th>Server Access</th>
<th>Status</th>
<th>Last Login</th>
<th style={{ textAlign: "right" }}>Actions</th>
</tr>
</thead>
<tbody>
{items.map((item) => {
const role = getRoleMeta(item.role);
const RoleIcon = role.icon;
const isSelf = currentUserId === item.id;
const access = getServerAccessSummary({
role: item.role,
allServersAccess: item.allServersAccess,
names: item.serverAssignments.map(
(assignment) => assignment.server.name,
),
});
const canEditAccess =
canManageAccess &&
!(item.role === "SUPER_ADMIN" && currentUserId !== undefined);
const canEditRole =
canManageRoles && item.role !== "SUPER_ADMIN";
const canDeactivate =
canManageRoles && item.role !== "SUPER_ADMIN" && !isSelf;
return (
<tr key={item.id}>
<td>
<div
style={{
display: "flex",
alignItems: "center",
gap: 10,
}}
>
<div
style={{
width: 34,
height: 34,
borderRadius: 8,
background:
"linear-gradient(135deg, #3b82f6, #8b5cf6)",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 13,
fontWeight: 700,
color: "white",
}}
>
{getInitials(item.name)}
</div>
<div>
<p
style={{
fontSize: 13,
fontWeight: 600,
color: "var(--text-primary)",
}}
>
{item.name}
</p>
<p
style={{ fontSize: 11, color: "var(--text-muted)" }}
>
{item.email}
</p>
</div>
</div>
</td>
<td>
<span
style={{
display: "inline-flex",
alignItems: "center",
gap: 5,
background: `${role.color}15`,
color: role.color,
border: `1px solid ${role.color}25`,
padding: "3px 10px",
borderRadius: 6,
fontSize: 11,
fontWeight: 600,
}}
>
<RoleIcon size={10} /> {role.label}
</span>
</td>
<td>
<span
style={{
fontSize: 10,
background: access.tone,
color: access.color,
border: `1px solid ${access.border}`,
padding: "2px 7px",
borderRadius: 4,
}}
>
{access.label}
</span>
</td>
<td>
<span
style={{
background: item.isActive
? "rgba(16,185,129,0.1)"
: "rgba(100,116,139,0.1)",
color: item.isActive ? "#10b981" : "#64748b",
border: `1px solid ${item.isActive ? "rgba(16,185,129,0.3)" : "rgba(100,116,139,0.3)"}`,
padding: "2px 9px",
borderRadius: 6,
fontSize: 11,
fontWeight: 600,
display: "inline-flex",
alignItems: "center",
gap: 4,
}}
>
{item.isActive ? (
<span
className="animate-pulse-dot"
style={{
display: "inline-block",
width: 5,
height: 5,
borderRadius: "50%",
background: "currentColor",
}}
/>
) : null}
{item.isActive ? "active" : "inactive"}
</span>
</td>
<td
style={{
fontSize: 11,
color: "var(--text-muted)",
fontFamily: "JetBrains Mono, monospace",
}}
>
<Clock
size={10}
style={{
display: "inline",
marginRight: 4,
verticalAlign: "middle",
}}
/>
{formatRelativeDate(item.lastLogin)}
</td>
<td>
<div
style={{
display: "flex",
justifyContent: "flex-end",
gap: 5,
}}
>
<button
className="btn btn-ghost"
style={{ padding: "5px 8px" }}
title="Server access"
onClick={() => onEditAccess(item)}
disabled={
!canEditAccess ||
mutatingKey === `access:${item.id}`
}
>
{mutatingKey === `access:${item.id}` ? (
<Loader2 size={11} className="animate-spin" />
) : (
<Shield size={11} />
)}
</button>
<button
className="btn btn-ghost"
style={{ padding: "5px 8px" }}
title="Edit role"
onClick={() => onEditRole(item)}
disabled={
!canEditRole || mutatingKey === `role:${item.id}`
}
>
{mutatingKey === `role:${item.id}` ? (
<Loader2 size={11} className="animate-spin" />
) : (
<Edit3 size={11} />
)}
</button>
{canDeactivate ? (
<button
className="btn btn-danger"
style={{ padding: "5px 8px" }}
title="Delete user"
onClick={() => void onDelete(item)}
disabled={mutatingKey === `delete:${item.id}`}
>
{mutatingKey === `delete:${item.id}` ? (
<Loader2 size={11} className="animate-spin" />
) : (
<Trash2 size={11} />
)}
</button>
) : (
<button
className="btn btn-ghost"
style={{
padding: "5px 8px",
opacity: 0.7,
cursor: "default",
}}
title={
item.role === "SUPER_ADMIN"
? "Protected Super Admin account"
: isSelf
? "Current user"
: "Locked action"
}
disabled
>
<Lock size={11} />
</button>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
<TablePagination
currentPage={currentPage}
totalPages={totalPages}
totalItems={totalItems}
startItem={startItem}
endItem={endItem}
itemLabel="users"
onPageChange={onPageChange}
/>
</>
)}
</div>
);
}
@@ -0,0 +1,236 @@
import TablePagination from "@/components/TablePagination";
import { Clock, Lock } from "lucide-react";
import type { UserRecord } from "@/lib/api";
import { getRoleMeta } from "@/app/users/components/user-role-config";
import {
formatRelativeDate,
getInitials,
getServerAccessSummary,
} from "@/app/users/components/user-utils";
interface ArchivedUsersPanelProps {
loading: boolean;
items: UserRecord[];
currentPage: number;
totalPages: number;
totalItems: number;
startItem: number;
endItem: number;
onPageChange: (page: number) => void;
}
export default function ArchivedUsersPanel({
loading,
items,
currentPage,
totalPages,
totalItems,
startItem,
endItem,
onPageChange,
}: ArchivedUsersPanelProps) {
return (
<div className="card" style={{ overflow: "hidden" }}>
<div
style={{
padding: "12px 16px",
borderBottom: "1px solid var(--border)",
display: "flex",
alignItems: "center",
gap: 8,
}}
>
<Lock size={14} style={{ color: "var(--text-secondary)" }} />
<div>
<p
style={{
fontSize: 13,
fontWeight: 700,
color: "var(--text-primary)",
}}
>
Archived / Inactive Users
</p>
<p style={{ fontSize: 11, color: "var(--text-muted)" }}>
Legacy accounts that were previously deactivated remain visible here
for reference.
</p>
</div>
</div>
{loading ? (
<div
style={{
padding: 28,
textAlign: "center",
color: "var(--text-muted)",
fontSize: 13,
}}
>
Loading archived users...
</div>
) : items.length === 0 ? (
<div
style={{
padding: 28,
textAlign: "center",
color: "var(--text-muted)",
fontSize: 13,
}}
>
No archived users.
</div>
) : (
<>
<table className="data-table">
<thead>
<tr>
<th>User</th>
<th>Role</th>
<th>Server Access</th>
<th>Status</th>
<th>Last Login</th>
</tr>
</thead>
<tbody>
{items.map((item) => {
const role = getRoleMeta(item.role);
const RoleIcon = role.icon;
const access = getServerAccessSummary({
role: item.role,
allServersAccess: item.allServersAccess,
names: item.serverAssignments.map(
(assignment) => assignment.server.name,
),
});
return (
<tr key={item.id}>
<td>
<div
style={{
display: "flex",
alignItems: "center",
gap: 10,
}}
>
<div
style={{
width: 34,
height: 34,
borderRadius: 8,
background:
"linear-gradient(135deg, rgba(100,116,139,0.9), rgba(71,85,105,0.9))",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 13,
fontWeight: 700,
color: "white",
}}
>
{getInitials(item.name)}
</div>
<div>
<p
style={{
fontSize: 13,
fontWeight: 600,
color: "var(--text-primary)",
}}
>
{item.name}
</p>
<p
style={{ fontSize: 11, color: "var(--text-muted)" }}
>
{item.email}
</p>
</div>
</div>
</td>
<td>
<span
style={{
display: "inline-flex",
alignItems: "center",
gap: 5,
background: `${role.color}15`,
color: role.color,
border: `1px solid ${role.color}25`,
padding: "3px 10px",
borderRadius: 6,
fontSize: 11,
fontWeight: 600,
}}
>
<RoleIcon size={10} /> {role.label}
</span>
</td>
<td>
<span
style={{
fontSize: 10,
background: access.tone,
color: access.color,
border: `1px solid ${access.border}`,
padding: "2px 7px",
borderRadius: 4,
}}
>
{access.label}
</span>
</td>
<td>
<span
style={{
background: "rgba(100,116,139,0.1)",
color: "#64748b",
border: "1px solid rgba(100,116,139,0.3)",
padding: "2px 9px",
borderRadius: 6,
fontSize: 11,
fontWeight: 600,
display: "inline-flex",
alignItems: "center",
gap: 4,
}}
>
inactive
</span>
</td>
<td
style={{
fontSize: 11,
color: "var(--text-muted)",
fontFamily: "JetBrains Mono, monospace",
}}
>
<Clock
size={10}
style={{
display: "inline",
marginRight: 4,
verticalAlign: "middle",
}}
/>
{formatRelativeDate(item.lastLogin)}
</td>
</tr>
);
})}
</tbody>
</table>
<TablePagination
currentPage={currentPage}
totalPages={totalPages}
totalItems={totalItems}
startItem={startItem}
endItem={endItem}
itemLabel="archived users"
onPageChange={onPageChange}
/>
</>
)}
</div>
);
}
+158
View File
@@ -0,0 +1,158 @@
"use client";
import { AlertCircle, Edit3, Loader2 } from "lucide-react";
import { useState } from "react";
import type { UserRecord, UserRole } from "@/lib/api";
import { manageableRoles } from "@/app/users/components/user-role-config";
interface EditRoleModalProps {
user: UserRecord;
onClose: () => void;
onSubmit: (role: Exclude<UserRole, "SUPER_ADMIN">) => Promise<void>;
submitting: boolean;
error: string;
}
export default function EditRoleModal({
user,
onClose,
onSubmit,
submitting,
error,
}: EditRoleModalProps) {
const [role, setRole] = useState<Exclude<UserRole, "SUPER_ADMIN">>(
user.role as Exclude<UserRole, "SUPER_ADMIN">,
);
return (
<div className="modal-overlay" onClick={onClose}>
<div
className="modal"
style={{ maxWidth: 420 }}
onClick={(event) => event.stopPropagation()}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 20,
}}
>
<div>
<h3
style={{
fontSize: 15,
fontWeight: 700,
color: "var(--text-primary)",
}}
>
Edit Role
</h3>
<p style={{ fontSize: 12, color: "var(--text-muted)", marginTop: 2 }}>
{user.email}
</p>
</div>
<button
type="button"
onClick={onClose}
style={{
background: "none",
border: "none",
color: "var(--text-muted)",
cursor: "pointer",
fontSize: 18,
}}
>
x
</button>
</div>
{error ? (
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
background: "rgba(239,68,68,0.1)",
border: "1px solid rgba(239,68,68,0.3)",
color: "#ef4444",
padding: "10px 12px",
borderRadius: 8,
fontSize: 12,
marginBottom: 12,
}}
>
<AlertCircle size={14} />
<span>{error}</span>
</div>
) : null}
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{manageableRoles.map((item) => {
const Icon = item.icon;
return (
<label
key={item.id}
style={{
display: "flex",
alignItems: "center",
gap: 10,
background: "var(--bg-input)",
padding: "10px 12px",
borderRadius: 8,
border: `1px solid ${role === item.id ? item.color : "var(--border)"}`,
cursor: "pointer",
}}
>
<input
type="radio"
checked={role === item.id}
name="role"
onChange={() =>
setRole(item.id as Exclude<UserRole, "SUPER_ADMIN">)
}
style={{ accentColor: item.color }}
/>
<Icon size={13} style={{ color: item.color }} />
<div>
<p
style={{
fontSize: 12,
fontWeight: 600,
color: "var(--text-primary)",
}}
>
{item.label}
</p>
<p style={{ fontSize: 11, color: "var(--text-muted)" }}>
{item.desc}
</p>
</div>
</label>
);
})}
</div>
<div style={{ display: "flex", gap: 10, marginTop: 16 }}>
<button className="btn btn-ghost" style={{ flex: 1 }} onClick={onClose}>
Cancel
</button>
<button
className="btn btn-primary"
style={{ flex: 1 }}
onClick={() => void onSubmit(role)}
disabled={submitting}
>
{submitting ? (
<Loader2 size={12} className="animate-spin" />
) : (
<Edit3 size={12} />
)}
Save Role
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,385 @@
"use client";
import { AlertCircle, CheckCircle, Copy, Key, Loader2 } from "lucide-react";
import { useState } from "react";
import type {
CreateUserInvitationBody,
Server as ServerRecord,
} from "@/lib/api";
import ServerAccessSelector from "@/app/users/components/ServerAccessSelector";
import { manageableRoles } from "@/app/users/components/user-role-config";
import {
copyText,
formatRelativeDate,
} from "@/app/users/components/user-utils";
interface InviteUserModalProps {
availableServers: ServerRecord[];
onClose: () => void;
onSubmit: (payload: CreateUserInvitationBody) => Promise<void>;
onCopySuccess: () => void;
submitting: boolean;
error: string;
result: { inviteUrl: string; email: string; expiresAt: string } | null;
}
export default function InviteUserModal({
availableServers,
onClose,
onSubmit,
onCopySuccess,
submitting,
error,
result,
}: InviteUserModalProps) {
const [form, setForm] = useState<CreateUserInvitationBody>({
name: "",
email: "",
role: "DEVELOPER",
allServersAccess: true,
serverIds: [],
expiresInDays: 7,
});
const [copied, setCopied] = useState(false);
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
await onSubmit(form);
};
const handleCopy = async () => {
if (!result) return;
await copyText(result.inviteUrl);
setCopied(true);
onCopySuccess();
window.setTimeout(() => setCopied(false), 2000);
};
return (
<div className="modal-overlay" onClick={onClose}>
<div
className="modal"
style={{ maxWidth: 560 }}
onClick={(event) => event.stopPropagation()}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 20,
}}
>
<div>
<h3
style={{
fontSize: 15,
fontWeight: 700,
color: "var(--text-primary)",
}}
>
Invite User
</h3>
<p
style={{ fontSize: 12, color: "var(--text-muted)", marginTop: 2 }}
>
Generate an onboarding link so the user sets their own password.
</p>
</div>
<button
type="button"
onClick={onClose}
style={{
background: "none",
border: "none",
color: "var(--text-muted)",
cursor: "pointer",
fontSize: 18,
}}
>
x
</button>
</div>
{result ? (
<div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
<div
style={{
display: "flex",
alignItems: "center",
gap: 10,
background: "rgba(16,185,129,0.08)",
border: "1px solid rgba(16,185,129,0.28)",
borderRadius: 10,
padding: "12px 14px",
color: "#10b981",
}}
>
<CheckCircle size={16} />
<div>
<p style={{ fontSize: 12, fontWeight: 700 }}>
Invitation link ready
</p>
<p style={{ fontSize: 11, color: "var(--text-secondary)" }}>
Send this link to {result.email}. It expires{" "}
{formatRelativeDate(result.expiresAt)}.
</p>
</div>
</div>
<div
style={{
background: "var(--bg-input)",
border: "1px solid var(--border)",
borderRadius: 10,
padding: 14,
}}
>
<p
style={{
fontSize: 11,
color: "var(--text-muted)",
marginBottom: 8,
}}
>
Invitation Link
</p>
<p
style={{
fontSize: 12,
color: "var(--text-primary)",
wordBreak: "break-all",
fontFamily: "JetBrains Mono, monospace",
}}
>
{result.inviteUrl}
</p>
</div>
<div style={{ display: "flex", gap: 10 }}>
<button
className="btn btn-ghost"
style={{ flex: 1 }}
onClick={onClose}
>
Close
</button>
<button
className="btn btn-primary"
style={{ flex: 1 }}
onClick={() => void handleCopy()}
>
{copied ? <CheckCircle size={12} /> : <Copy size={12} />}
{copied ? "Copied" : "Copy Invite Link"}
</button>
</div>
</div>
) : (
<form
onSubmit={handleSubmit}
style={{ display: "flex", flexDirection: "column", gap: 12 }}
>
{error ? (
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
background: "rgba(239,68,68,0.1)",
border: "1px solid rgba(239,68,68,0.3)",
color: "#ef4444",
padding: "10px 12px",
borderRadius: 8,
fontSize: 12,
}}
>
<AlertCircle size={14} />
<span>{error}</span>
</div>
) : null}
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
gap: 12,
}}
>
<div>
<label
style={{
fontSize: 12,
color: "var(--text-secondary)",
marginBottom: 5,
display: "block",
fontWeight: 500,
}}
>
Full Name
</label>
<input
className="input"
placeholder="Jane Doe"
value={form.name}
onChange={(event) =>
setForm((current) => ({
...current,
name: event.target.value,
}))
}
required
/>
</div>
<div>
<label
style={{
fontSize: 12,
color: "var(--text-secondary)",
marginBottom: 5,
display: "block",
fontWeight: 500,
}}
>
Email Address
</label>
<input
className="input"
placeholder="user@example.com"
type="email"
value={form.email}
onChange={(event) =>
setForm((current) => ({
...current,
email: event.target.value,
}))
}
required
/>
</div>
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
gap: 12,
}}
>
<div>
<label
style={{
fontSize: 12,
color: "var(--text-secondary)",
marginBottom: 5,
display: "block",
fontWeight: 500,
}}
>
Role
</label>
<select
className="input"
value={form.role}
onChange={(event) =>
setForm((current) => ({
...current,
role: event.target
.value as CreateUserInvitationBody["role"],
}))
}
>
{manageableRoles.map((role) => (
<option key={role.id} value={role.id}>
{role.label}
</option>
))}
</select>
</div>
<div>
<label
style={{
fontSize: 12,
color: "var(--text-secondary)",
marginBottom: 5,
display: "block",
fontWeight: 500,
}}
>
Link Expiry
</label>
<select
className="input"
value={form.expiresInDays ?? 7}
onChange={(event) =>
setForm((current) => ({
...current,
expiresInDays: Number(event.target.value),
}))
}
>
<option value={3}>3 days</option>
<option value={7}>7 days</option>
<option value={14}>14 days</option>
<option value={30}>30 days</option>
</select>
</div>
</div>
<div>
<label
style={{
fontSize: 12,
color: "var(--text-secondary)",
marginBottom: 8,
display: "block",
fontWeight: 500,
}}
>
Server Access
</label>
<ServerAccessSelector
availableServers={availableServers}
allServersAccess={form.allServersAccess}
selectedServerIds={form.serverIds}
onAllServersChange={(value) =>
setForm((current) => ({
...current,
allServersAccess: value,
serverIds: value ? [] : current.serverIds,
}))
}
onSelectionChange={(value) =>
setForm((current) => ({ ...current, serverIds: value }))
}
/>
</div>
<div style={{ display: "flex", gap: 10, marginTop: 8 }}>
<button
className="btn btn-ghost"
style={{ flex: 1 }}
type="button"
onClick={onClose}
>
Cancel
</button>
<button
className="btn btn-primary"
style={{ flex: 1 }}
type="submit"
disabled={submitting}
>
{submitting ? (
<Loader2 size={12} className="animate-spin" />
) : (
<Key size={12} />
)}
Generate Invite
</button>
</div>
</form>
)}
</div>
</div>
);
}
@@ -0,0 +1,231 @@
import TablePagination from "@/components/TablePagination";
import { CheckCircle, Key, Loader2, Trash2, Users } from "lucide-react";
import type { UserInvitationRecord } from "@/lib/api";
import { getRoleMeta } from "@/app/users/components/user-role-config";
import {
formatRelativeDate,
getServerAccessSummary,
} from "@/app/users/components/user-utils";
interface PendingInvitesPanelProps {
loading: boolean;
items: UserInvitationRecord[];
copiedInviteId: string | null;
mutatingKey: string | null;
currentPage: number;
totalPages: number;
totalItems: number;
startItem: number;
endItem: number;
onPageChange: (page: number) => void;
onCopyFreshInviteLink: (invitationId: string) => void | Promise<void>;
onRevokeInvitation: (invitationId: string) => void | Promise<void>;
}
export default function PendingInvitesPanel({
loading,
items,
copiedInviteId,
mutatingKey,
currentPage,
totalPages,
totalItems,
startItem,
endItem,
onPageChange,
onCopyFreshInviteLink,
onRevokeInvitation,
}: PendingInvitesPanelProps) {
return (
<div className="card" style={{ overflow: "hidden" }}>
<div
style={{
padding: "12px 16px",
borderBottom: "1px solid var(--border)",
display: "flex",
alignItems: "center",
gap: 8,
}}
>
<Users size={14} style={{ color: "var(--text-secondary)" }} />
<div>
<p
style={{
fontSize: 13,
fontWeight: 700,
color: "var(--text-primary)",
}}
>
Pending Invitations
</p>
<p style={{ fontSize: 11, color: "var(--text-muted)" }}>
Copy a fresh invite link or revoke an unused invitation.
</p>
</div>
</div>
{loading ? (
<div
style={{
padding: 28,
textAlign: "center",
color: "var(--text-muted)",
fontSize: 13,
}}
>
Loading invitations...
</div>
) : items.length === 0 ? (
<div
style={{
padding: 28,
textAlign: "center",
color: "var(--text-muted)",
fontSize: 13,
}}
>
No pending invitations.
</div>
) : (
<>
<table className="data-table">
<thead>
<tr>
<th>Invitee</th>
<th>Role</th>
<th>Server Access</th>
<th>Expires</th>
<th style={{ textAlign: "right" }}>Actions</th>
</tr>
</thead>
<tbody>
{items.map((invitation) => {
const role = getRoleMeta(invitation.role);
const InvitationIcon = role.icon;
const access = getServerAccessSummary({
role: invitation.role,
allServersAccess: invitation.allServersAccess,
names: invitation.servers.map((server) => server.name),
});
return (
<tr key={invitation.id}>
<td>
<div>
<p
style={{
fontSize: 13,
fontWeight: 600,
color: "var(--text-primary)",
}}
>
{invitation.name}
</p>
<p style={{ fontSize: 11, color: "var(--text-muted)" }}>
{invitation.email}
</p>
</div>
</td>
<td>
<span
style={{
display: "inline-flex",
alignItems: "center",
gap: 5,
background: `${role.color}15`,
color: role.color,
border: `1px solid ${role.color}25`,
padding: "3px 10px",
borderRadius: 6,
fontSize: 11,
fontWeight: 600,
}}
>
<InvitationIcon size={10} /> {role.label}
</span>
</td>
<td>
<span
style={{
fontSize: 10,
background: access.tone,
color: access.color,
border: `1px solid ${access.border}`,
padding: "2px 7px",
borderRadius: 4,
}}
>
{access.label}
</span>
</td>
<td
style={{
fontSize: 11,
color: "var(--text-muted)",
fontFamily: "JetBrains Mono, monospace",
}}
>
{formatRelativeDate(invitation.expiresAt)}
</td>
<td>
<div
style={{
display: "flex",
justifyContent: "flex-end",
gap: 5,
}}
>
<button
className="btn btn-ghost"
style={{ padding: "5px 8px" }}
title="Copy fresh invite link"
onClick={() =>
void onCopyFreshInviteLink(invitation.id)
}
disabled={
mutatingKey === `invite-link:${invitation.id}`
}
>
{mutatingKey === `invite-link:${invitation.id}` ? (
<Loader2 size={11} className="animate-spin" />
) : copiedInviteId === invitation.id ? (
<CheckCircle size={11} />
) : (
<Key size={11} />
)}
</button>
<button
className="btn btn-danger"
style={{ padding: "5px 8px" }}
title="Revoke invitation"
onClick={() => void onRevokeInvitation(invitation.id)}
disabled={
mutatingKey === `revoke-invite:${invitation.id}`
}
>
{mutatingKey === `revoke-invite:${invitation.id}` ? (
<Loader2 size={11} className="animate-spin" />
) : (
<Trash2 size={11} />
)}
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
<TablePagination
currentPage={currentPage}
totalPages={totalPages}
totalItems={totalItems}
startItem={startItem}
endItem={endItem}
itemLabel="invitations"
onPageChange={onPageChange}
/>
</>
)}
</div>
);
}
@@ -0,0 +1,146 @@
"use client";
import { AlertCircle, Loader2, Shield } from "lucide-react";
import { useState } from "react";
import type { Server as ServerRecord, UserRecord } from "@/lib/api";
import ServerAccessSelector from "@/app/users/components/ServerAccessSelector";
interface ServerAccessModalProps {
user: UserRecord;
availableServers: ServerRecord[];
onClose: () => void;
onSubmit: (payload: {
allServersAccess: boolean;
serverIds: string[];
}) => Promise<void>;
submitting: boolean;
error: string;
}
export default function ServerAccessModal({
user,
availableServers,
onClose,
onSubmit,
submitting,
error,
}: ServerAccessModalProps) {
const [allServersAccess, setAllServersAccess] = useState(
user.role === "SUPER_ADMIN" ? true : user.allServersAccess,
);
const [serverIds, setServerIds] = useState(
user.serverAssignments.map((assignment) => assignment.serverId),
);
return (
<div className="modal-overlay" onClick={onClose}>
<div
className="modal"
style={{ maxWidth: 600 }}
onClick={(event) => event.stopPropagation()}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 20,
}}
>
<div>
<h3
style={{
fontSize: 15,
fontWeight: 700,
color: "var(--text-primary)",
}}
>
Server Access
</h3>
<p style={{ fontSize: 12, color: "var(--text-muted)", marginTop: 2 }}>
{user.email}
</p>
</div>
<button
type="button"
onClick={onClose}
style={{
background: "none",
border: "none",
color: "var(--text-muted)",
cursor: "pointer",
fontSize: 18,
}}
>
x
</button>
</div>
{error ? (
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
background: "rgba(239,68,68,0.1)",
border: "1px solid rgba(239,68,68,0.3)",
color: "#ef4444",
padding: "10px 12px",
borderRadius: 8,
fontSize: 12,
marginBottom: 12,
}}
>
<AlertCircle size={14} />
<span>{error}</span>
</div>
) : null}
{user.role === "SUPER_ADMIN" ? (
<div
style={{
padding: 14,
borderRadius: 10,
border: "1px solid rgba(239,68,68,0.25)",
background: "rgba(239,68,68,0.08)",
color: "var(--text-secondary)",
fontSize: 12,
}}
>
Super Admin always has access to all servers.
</div>
) : (
<ServerAccessSelector
availableServers={availableServers}
allServersAccess={allServersAccess}
selectedServerIds={serverIds}
onAllServersChange={(value) => {
setAllServersAccess(value);
if (value) setServerIds([]);
}}
onSelectionChange={setServerIds}
/>
)}
<div style={{ display: "flex", gap: 10, marginTop: 16 }}>
<button className="btn btn-ghost" style={{ flex: 1 }} onClick={onClose}>
Cancel
</button>
<button
className="btn btn-primary"
style={{ flex: 1 }}
onClick={() => void onSubmit({ allServersAccess, serverIds })}
disabled={submitting}
>
{submitting ? (
<Loader2 size={12} className="animate-spin" />
) : (
<Shield size={12} />
)}
Save Access
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,118 @@
"use client";
import type { Server as ServerRecord } from "@/lib/api";
interface ServerAccessSelectorProps {
availableServers: ServerRecord[];
allServersAccess: boolean;
selectedServerIds: string[];
onAllServersChange: (value: boolean) => void;
onSelectionChange: (value: string[]) => void;
}
export default function ServerAccessSelector({
availableServers,
allServersAccess,
selectedServerIds,
onAllServersChange,
onSelectionChange,
}: ServerAccessSelectorProps) {
const toggleServer = (serverId: string) => {
if (selectedServerIds.includes(serverId)) {
onSelectionChange(
selectedServerIds.filter((value) => value !== serverId),
);
return;
}
onSelectionChange([...selectedServerIds, serverId]);
};
return (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<div style={{ display: "flex", gap: 8 }}>
<button
type="button"
className="btn btn-ghost"
onClick={() => onAllServersChange(true)}
style={{
flex: 1,
borderColor: allServersAccess ? "rgba(16,185,129,0.35)" : undefined,
color: allServersAccess ? "#10b981" : undefined,
background: allServersAccess ? "rgba(16,185,129,0.08)" : undefined,
}}
>
All Servers
</button>
<button
type="button"
className="btn btn-ghost"
onClick={() => onAllServersChange(false)}
style={{
flex: 1,
borderColor: !allServersAccess
? "rgba(59,130,246,0.35)"
: undefined,
color: !allServersAccess ? "#3b82f6" : undefined,
background: !allServersAccess ? "rgba(59,130,246,0.08)" : undefined,
}}
>
Selected Servers
</button>
</div>
{!allServersAccess ? (
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
gap: 8,
maxHeight: 220,
overflowY: "auto",
}}
>
{availableServers.map((server) => {
const checked = selectedServerIds.includes(server.id);
return (
<label
key={server.id}
style={{
display: "flex",
alignItems: "center",
gap: 10,
background: checked
? "rgba(59,130,246,0.08)"
: "var(--bg-input)",
border: `1px solid ${checked ? "rgba(59,130,246,0.28)" : "var(--border)"}`,
borderRadius: 8,
padding: "10px 12px",
cursor: "pointer",
}}
>
<input
type="checkbox"
checked={checked}
onChange={() => toggleServer(server.id)}
style={{ accentColor: "#3b82f6" }}
/>
<div>
<p
style={{
fontSize: 12,
color: "var(--text-primary)",
fontWeight: 600,
}}
>
{server.name}
</p>
<p style={{ fontSize: 10, color: "var(--text-muted)" }}>
{server.ip}
</p>
</div>
</label>
);
})}
</div>
) : null}
</div>
);
}
@@ -0,0 +1,68 @@
import { roles } from "@/app/users/components/user-role-config";
import type { UserRole } from "@/lib/api";
interface UsersRoleSummaryProps {
roleCounts: Record<UserRole, number>;
}
export default function UsersRoleSummary({
roleCounts,
}: UsersRoleSummaryProps) {
return (
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))",
gap: 12,
}}
>
{roles.map((role) => {
const Icon = role.icon;
return (
<div key={role.id} className="card" style={{ padding: 16 }}>
<div
style={{
display: "flex",
alignItems: "center",
gap: 10,
marginBottom: 8,
}}
>
<div
style={{
width: 34,
height: 34,
borderRadius: 8,
background: `${role.color}15`,
border: `1px solid ${role.color}25`,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Icon size={15} style={{ color: role.color }} />
</div>
<div>
<p
style={{
fontSize: 13,
fontWeight: 700,
color: "var(--text-primary)",
}}
>
{role.label}
</p>
<p style={{ fontSize: 10, color: "var(--text-muted)" }}>
{roleCounts[role.id]} user(s)
</p>
</div>
</div>
<p style={{ fontSize: 11, color: "var(--text-muted)" }}>
{role.desc}
</p>
</div>
);
})}
</div>
);
}
+127
View File
@@ -0,0 +1,127 @@
import { Clock, Loader2, Lock, Plus, RefreshCw, Users } from "lucide-react";
export type UsersTabKey = "active-users" | "pending-invites" | "archived-users";
interface UsersToolbarProps {
activeTab: UsersTabKey;
activeUsersCount: number;
invitationsCount: number;
archivedUsersCount: number;
loading: boolean;
refreshing: boolean;
canInvite: boolean;
onTabChange: (tab: UsersTabKey) => void;
onRefresh: () => void | Promise<void>;
onInvite: () => void;
}
export default function UsersToolbar({
activeTab,
activeUsersCount,
invitationsCount,
archivedUsersCount,
loading,
refreshing,
canInvite,
onTabChange,
onRefresh,
onInvite,
}: UsersToolbarProps) {
const tabs = [
{
key: "active-users" as const,
label: `Active Users (${activeUsersCount})`,
icon: Users,
},
{
key: "pending-invites" as const,
label: `Pending Invites (${invitationsCount})`,
icon: Clock,
},
{
key: "archived-users" as const,
label: `Archived Users (${archivedUsersCount})`,
icon: Lock,
},
];
return (
<div
className="card"
style={{
padding: "12px 16px",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 12,
flexWrap: "wrap",
}}
>
<div
style={{
display: "flex",
gap: 4,
background: "var(--bg-card)",
borderRadius: 10,
padding: 4,
border: "1px solid var(--border)",
}}
>
{tabs.map((tab) => {
const Icon = tab.icon;
return (
<button
key={tab.key}
type="button"
onClick={() => onTabChange(tab.key)}
style={{
display: "flex",
alignItems: "center",
gap: 7,
padding: "7px 16px",
borderRadius: 7,
border: "none",
cursor: "pointer",
fontSize: 13,
fontWeight: 500,
background:
activeTab === tab.key
? "rgba(59,130,246,0.15)"
: "transparent",
color: activeTab === tab.key ? "#3b82f6" : "var(--text-muted)",
transition: "all 0.2s",
}}
>
<Icon size={13} />
{tab.label}
</button>
);
})}
</div>
<div style={{ display: "flex", gap: 8 }}>
<button
className="btn btn-ghost"
style={{ fontSize: 12 }}
onClick={() => void onRefresh()}
disabled={loading || refreshing}
>
{refreshing ? (
<Loader2 size={12} className="animate-spin" />
) : (
<RefreshCw size={12} />
)}
Refresh
</button>
{canInvite ? (
<button
className="btn btn-primary"
style={{ fontSize: 12 }}
onClick={onInvite}
>
<Plus size={12} /> Invite User
</button>
) : null}
</div>
</div>
);
}
@@ -0,0 +1,47 @@
import { Crown, Shield, User } from "lucide-react";
import type { UserRole } from "@/lib/api";
export const roles: Array<{
id: UserRole;
label: string;
color: string;
icon: typeof Crown;
desc: string;
}> = [
{
id: "SUPER_ADMIN",
label: "Super Admin",
color: "#ef4444",
icon: Crown,
desc: "Full access to everything",
},
{
id: "OPERATOR",
label: "Operator",
color: "#f59e0b",
icon: Shield,
desc: "Manage operational workflows",
},
{
id: "DEVELOPER",
label: "Developer",
color: "#3b82f6",
icon: User,
desc: "Deploy and operate assigned workloads",
},
{
id: "VIEWER",
label: "Viewer",
color: "#64748b",
icon: User,
desc: "Read-only visibility",
},
];
export const manageableRoles = roles.filter(
(role) => role.id !== "SUPER_ADMIN",
);
export function getRoleMeta(roleId: UserRole) {
return roles.find((role) => role.id === roleId) ?? roles[3];
}
+59
View File
@@ -0,0 +1,59 @@
import type { UserRole } from "@/lib/api";
export function getInitials(name: string) {
return name
.split(" ")
.map((part) => part[0]?.toUpperCase())
.filter(Boolean)
.slice(0, 2)
.join("");
}
export function formatRelativeDate(value: string | null) {
if (!value) return "Never";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "Never";
const diffSeconds = Math.max(
0,
Math.floor((Date.now() - date.getTime()) / 1000),
);
if (diffSeconds < 60) return `${diffSeconds}s ago`;
if (diffSeconds < 3600) return `${Math.floor(diffSeconds / 60)}m ago`;
if (diffSeconds < 86400) return `${Math.floor(diffSeconds / 3600)}h ago`;
if (diffSeconds < 604800) return `${Math.floor(diffSeconds / 86400)}d ago`;
return date.toLocaleString();
}
export function getServerAccessSummary(input: {
role: UserRole;
allServersAccess: boolean;
names: string[];
}) {
if (input.role === "SUPER_ADMIN" || input.allServersAccess) {
return {
label: "All servers",
color: "#10b981",
tone: "rgba(16,185,129,0.1)",
border: "rgba(16,185,129,0.25)",
};
}
if (input.names.length === 0) {
return {
label: "No servers assigned",
color: "#f59e0b",
tone: "rgba(245,158,11,0.1)",
border: "rgba(245,158,11,0.25)",
};
}
return {
label: input.names.join(", "),
color: "#3b82f6",
tone: "rgba(59,130,246,0.08)",
border: "rgba(59,130,246,0.2)",
};
}
export function copyText(value: string) {
return navigator.clipboard.writeText(value).catch(() => undefined);
}