diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/project-settings/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/project-settings/page-client.tsx
index e3f06db45..39004c5d0 100644
--- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/project-settings/page-client.tsx
+++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/project-settings/page-client.tsx
@@ -5,19 +5,39 @@ import { Link, StyledLink } from "@/components/link";
import { LogoUpload } from "@/components/logo-upload";
import {
DesignAlert,
+ DesignBadge,
DesignButton,
DesignCard,
+ DesignDialog,
+ DesignDialogClose,
DesignEditableGrid,
+ DesignInput,
+ DesignPillToggle,
type DesignEditableGridItem,
} from "@/components/design-components";
import { ActionDialog, Avatar, AvatarFallback, AvatarImage, SimpleTooltip, Switch, useToast } from "@/components/ui";
import { useDashboardInternalUser } from "@/lib/dashboard-user";
import { getPublicEnvVar } from "@/lib/env";
-import type { PushedConfigSource } from "@hexclave/next";
+import {
+ DataGrid,
+ createDefaultDataGridState,
+ useDataSource,
+ type DataGridColumnDef,
+} from "@hexclave/dashboard-ui-components";
+import type { PushedConfigSource, TeamUser } from "@hexclave/next";
import { TeamSwitcher } from "@hexclave/next";
+import { fromNow } from "@hexclave/shared/dist/utils/dates";
import { throwErr } from "@hexclave/shared/dist/utils/errors";
import { runAsynchronouslyWithAlert } from "@hexclave/shared/dist/utils/promises";
-import { ArrowsLeftRightIcon, BuildingsIcon, GearIcon, GlobeHemisphereWestIcon, ImageIcon, WarningIcon } from "@phosphor-icons/react";
+import {
+ CaretRightIcon,
+ DiamondIcon,
+ MagnifyingGlassIcon,
+ PencilSimpleIcon,
+ UploadSimpleIcon,
+ UsersIcon,
+ WarningIcon,
+} from "@phosphor-icons/react";
import { useCallback, useEffect, useMemo, useState } from "react";
import * as yup from "yup";
import { PageLayout } from "../page-layout";
@@ -28,30 +48,76 @@ const projectInformationSchema = yup.object().shape({
description: yup.string(),
});
-function TeamMemberItem({ member }: { member: any }) {
- const displayName = member.teamProfile.displayName?.trim() || "Name not set";
- const avatarFallback = displayName === "Name not set"
- ? "?"
- : displayName.charAt(0).toUpperCase();
+type ConfigSourceId = "github" | "cli" | "dashboard";
+type MemberRole = "Admin" | "Member";
- return (
-
-
-
- {avatarFallback}
-
-
- {displayName}
- {displayName === "Name not set" && (
-
- Display name not set
-
- )}
-
-
- );
+type AccessMemberRow = {
+ id: string,
+ displayName: string,
+ profileImageUrl: string | null,
+ role: MemberRole,
+ lastActiveAt: Date | null,
+};
+
+type LastActiveByUserId = Map;
+
+function getConfigSourceId(source: PushedConfigSource | null): ConfigSourceId {
+ if (source?.type === "pushed-from-github") return "github";
+ if (source?.type === "pushed-from-unknown") return "cli";
+ return "dashboard";
}
+function roleBadgeColor(role: MemberRole): "red" | "blue" | "green" {
+ if (role === "Admin") return "blue";
+ return "green";
+}
+
+const accessMemberColumns: DataGridColumnDef[] = [
+ {
+ id: "member",
+ header: "Member",
+ accessor: "displayName",
+ type: "string",
+ width: 240,
+ renderCell: ({ row }) => {
+ const initials = row.displayName === "Name not set"
+ ? "?"
+ : row.displayName.slice(0, 2).toUpperCase();
+ return (
+
+
+
+ {initials}
+
+
{row.displayName}
+
+ );
+ },
+ },
+ {
+ id: "role",
+ header: "Role",
+ accessor: "role",
+ type: "string",
+ width: 120,
+ renderCell: ({ row }) => (
+
+ ),
+ },
+ {
+ id: "lastActiveAt",
+ header: "Last activity",
+ accessor: (row) => row.lastActiveAt?.getTime() ?? 0,
+ type: "number",
+ width: 160,
+ renderCell: ({ row }) => (
+
+ {row.lastActiveAt ? fromNow(row.lastActiveAt) : "Never"}
+
+ ),
+ },
+];
+
export default function PageClient() {
const hexclaveAdminApp = useAdminApp();
const project = hexclaveAdminApp.useProject();
@@ -63,9 +129,15 @@ export default function PageClient() {
const [configSource, setConfigSource] = useState(null);
const [isLoadingSource, setIsLoadingSource] = useState(true);
const [isProjectDetailsDialogOpen, setIsProjectDetailsDialogOpen] = useState(false);
+ const [isLogoDialogOpen, setIsLogoDialogOpen] = useState(false);
+ const [isConfigExpanded, setIsConfigExpanded] = useState(false);
+ const [isUnlinkDialogOpen, setIsUnlinkDialogOpen] = useState(false);
+ const [configSourcePreview, setConfigSourcePreview] = useState(null);
+ const [memberSearch, setMemberSearch] = useState("");
+ const [adminUserIds, setAdminUserIds] = useState>(() => new Set());
+ const [lastActiveByUserId, setLastActiveByUserId] = useState(() => new Map());
const { toast } = useToast();
- // Fetch config source on mount
useEffect(() => {
runAsynchronouslyWithAlert(async () => {
try {
@@ -80,37 +152,33 @@ export default function PageClient() {
const handleUnlinkSource = useCallback(async () => {
await project.unlinkPushedConfigSource();
setConfigSource({ type: "unlinked" });
+ setConfigSourcePreview(null);
+ setIsUnlinkDialogOpen(false);
toast({ title: "Configuration source unlinked", description: "You can now edit the configuration directly on this dashboard." });
}, [project, toast]);
- const baseApiUrl = getPublicEnvVar('NEXT_PUBLIC_STACK_API_URL');
+ const baseApiUrl = getPublicEnvVar("NEXT_PUBLIC_STACK_API_URL");
- // Memoize computed URLs
const jwksUrl = useMemo(
() => `${baseApiUrl}/api/v1/projects/${project.id}/.well-known/jwks.json`,
[baseApiUrl, project.id]
);
-
const restrictedJwksUrl = useMemo(
() => `${jwksUrl}?include_restricted=true`,
[jwksUrl]
);
-
const allJwksUrl = useMemo(
() => `${jwksUrl}?include_anonymous=true`,
[jwksUrl]
);
- // Memoize current owner team lookup
const currentOwnerTeam = useMemo(
() => teams.find(team => team.id === project.ownerTeamId) ?? throwErr(`Owner team of project ${project.id} not found in user's teams?`, { projectId: project.id, teams }),
[teams, project.ownerTeamId, project.id]
);
- // Check if user has team_admin permission for the current team
const hasAdminPermissionForCurrentTeam = user.usePermission(currentOwnerTeam, "team_admin");
- // Memoize selected team lookup
const selectedTeam = useMemo(
() => teams.find(team => team.id === selectedTeamId),
[teams, selectedTeamId]
@@ -118,7 +186,43 @@ export default function PageClient() {
const currentTeamMembers = currentOwnerTeam.useUsers();
- // Memoize team settings path
+ useEffect(() => {
+ let cancelled = false;
+ runAsynchronouslyWithAlert(async () => {
+ // Owner-team membership comes from the dashboard user API (TeamUser), which
+ // doesn't include lastActiveAt or roles — pull those from the admin project APIs.
+ const [permissions, usersPage] = await Promise.all([
+ hexclaveAdminApp.listTeamMemberPermissions(currentOwnerTeam.id, { recursive: false }),
+ hexclaveAdminApp.listUsers({
+ teamId: currentOwnerTeam.id,
+ limit: 100,
+ orderBy: "lastActiveAt",
+ desc: true,
+ includeAnonymous: true,
+ includeRestricted: true,
+ }),
+ ]);
+ if (cancelled) return;
+
+ const nextAdmins = new Set();
+ for (const { userId, permissionId } of permissions) {
+ if (permissionId === "team_admin") {
+ nextAdmins.add(userId);
+ }
+ }
+ setAdminUserIds(nextAdmins);
+
+ const nextLastActive: LastActiveByUserId = new Map();
+ for (const member of usersPage) {
+ nextLastActive.set(member.id, member.lastActiveAt);
+ }
+ setLastActiveByUserId(nextLastActive);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [hexclaveAdminApp, currentOwnerTeam.id]);
+
const teamSettingsPath = useMemo(
() => `/projects?team_settings=${encodeURIComponent(currentOwnerTeam.id)}`,
[currentOwnerTeam.id]
@@ -131,21 +235,17 @@ export default function PageClient() {
setIsTransferring(true);
try {
await user.transferProject(project.id, selectedTeamId);
-
toast({
- title: 'Project transferred successfully',
- variant: 'success'
+ title: "Project transferred successfully",
+ variant: "success",
});
-
- // Reload the page to reflect changes
- // we don't actually need this, but it's a nicer UX as it clearly indicates to the user that a "big" change was made
+ // Reload so ownership changes are unmistakably reflected after a large action.
window.location.reload();
} finally {
setIsTransferring(false);
}
}, [selectedTeamId, project.ownerTeamId, project.id, user, toast, isTransferring]);
- // Memoize logo update callbacks
const handleLogoChange = useCallback(async (logoUrl: string | null) => {
await project.update({ logoUrl });
}, [project]);
@@ -154,18 +254,15 @@ export default function PageClient() {
await project.update({ logoFullUrl });
}, [project]);
- // Memoize production mode change callback
const handleProductionModeChange = useCallback(async (checked: boolean) => {
await project.update({ isProductionMode: checked });
}, [project]);
- // Memoize team switcher change callback
- const handleTeamSwitcherChange = useCallback(async (team: any) => {
+ const handleTeamSwitcherChange = useCallback(async (team: { id: string }) => {
setSelectedTeamId(team.id);
}, []);
- // Memoize project details submit callback
- const handleProjectDetailsSubmit = useCallback(async (values: any) => {
+ const handleProjectDetailsSubmit = useCallback(async (values: yup.InferType) => {
await project.update(values);
}, [project]);
@@ -174,17 +271,49 @@ export default function PageClient() {
description: project.description || undefined,
}), [project.displayName, project.description]);
- // Memoize project delete callback
const handleProjectDelete = useCallback(async () => {
await project.delete();
await hexclaveAdminApp.redirectToHome();
}, [project, hexclaveAdminApp]);
+ const configSourceId = getConfigSourceId(configSource);
+ const isProductionReady = productionModeErrors.length === 0;
+
+ const accessRows = useMemo((): AccessMemberRow[] => {
+ return currentTeamMembers.map((member: TeamUser) => {
+ const displayName = member.teamProfile.displayName?.trim() || "Name not set";
+ return {
+ id: member.id,
+ displayName,
+ profileImageUrl: member.teamProfile.profileImageUrl,
+ role: adminUserIds.has(member.id) ? "Admin" : "Member",
+ lastActiveAt: lastActiveByUserId.get(member.id) ?? null,
+ };
+ });
+ }, [currentTeamMembers, adminUserIds, lastActiveByUserId]);
+
+ const filteredAccessRows = useMemo(() => {
+ const query = memberSearch.trim().toLowerCase();
+ if (query.length === 0) return accessRows;
+ return accessRows.filter((row) => row.displayName.toLowerCase().includes(query));
+ }, [accessRows, memberSearch]);
+
+ const [gridState, setGridState] = useState(() => createDefaultDataGridState(accessMemberColumns));
+ const gridData = useDataSource({
+ data: filteredAccessRows,
+ columns: accessMemberColumns,
+ getRowId: (row) => row.id,
+ sorting: gridState.sorting,
+ quickSearch: gridState.quickSearch,
+ pagination: gridState.pagination,
+ paginationMode: "client",
+ });
+
const productionModeItems: DesignEditableGridItem[] = [
{
itemKey: "production-mode",
type: "custom",
- icon: ,
+ icon: ,
name: "Enable production mode",
children: (
{
+ if (id === "github" || id === "cli" || id === "dashboard") {
+ setConfigSourcePreview(id);
+ return;
+ }
+ throwErr(`Unexpected configuration source preview "${id}"`);
+ };
+
return (
-
-
-
-
Project ID
-
+
+
Configuration source preview
+
+
+
+
+
+
+
+
setIsLogoDialogOpen(true)}
+ className="flex h-20 w-20 items-center justify-center overflow-hidden rounded-xl border border-border/60 bg-foreground/[0.03] transition-colors duration-150 hover:bg-foreground/[0.05] hover:transition-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
+ aria-label="Manage logo"
+ >
+ {project.logoUrl ? (
+ // eslint-disable-next-line @next/next/no-img-element
+
+ ) : (
+
+ )}
+
+
setIsLogoDialogOpen(true)}>
+ Manage Logo
+
+
+
+
+
+
+
{project.displayName}
+
setIsProjectDetailsDialogOpen(true)}
+ aria-label="Edit project details"
+ >
+
+
+
+
+ {project.description || "No project description has been added."}
+
+
+
+
+
+
- Looking for project API keys? Head over to the Project Keys page.
- >}
+ description={(
+ <>
+ Looking for project API keys?{" "}
+
+ Head over to the Project Keys Page
+
+ >
+ )}
/>
-
-
-
JWKS URLs
-
- More info about JWKS URLs
-
-
-
-
Standard
-
-
-
-
+ Restricted
-
- Info about restricted JWKS
-
+
+
setIsConfigExpanded((open) => !open)}
+ className="flex w-full items-center justify-between gap-3 px-4 py-3 text-left transition-colors duration-150 hover:bg-foreground/[0.03] hover:transition-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-xl"
+ >
+
+
+
+
Project Configuration
+
JWKS URLs and Configuration Source
+
-
-
-
-
+ Anonymous
-
- Info about anonymous JWKS
-
+
+
+
-
-
+
+
+ {isConfigExpanded && (
+
+
+
+
Configuration source
+
+ {selectedConfigSourcePreview !== configSourceId
+ ? `Previewing the ${selectedConfigSourcePreview === "github" ? "GitHub" : selectedConfigSourcePreview === "cli" ? "CLI" : "Dashboard"} configuration source.`
+ : configSource?.type === "pushed-from-github"
+ ? `${configSource.owner}/${configSource.repo} · ${configSource.branch}`
+ : configSource?.type === "pushed-from-unknown"
+ ? "Pushed via the Hexclave CLI."
+ : "Managed directly in this dashboard."}
+
+
+ {(configSource?.type === "pushed-from-github" || configSource?.type === "pushed-from-unknown") && selectedConfigSourcePreview === configSourceId && (
+
setIsUnlinkDialogOpen(true)}>
+ Unlink source
+
+ )}
+
+
+ {configSource?.type === "pushed-from-github" && (
+
+
Config file: {configSource.configFilePath}
+ {configSource.workflowPath ? (
+
+ ) : null}
+
Last commit: {configSource.commitHash.substring(0, 7)}
+
+ )}
+
+
+
+
JWKS URLs
+
+ More info about JWKS URLs
+
+
+
+
Standard
+
+
+ Restricted
+
+ Info about restricted JWKS
+
+
+
+
+ Anonymous
+
+ Info about anonymous JWKS
+
+
+
+
+
+
+ )}
+
setIsProjectDetailsDialogOpen(true)}>
- Edit
-
- )}
>
-
-
Display Name
-
{project.displayName}
+
+
+ Manage Team Members
+
+ }
+ placeholder="Search Members..."
+ value={memberSearch}
+ onChange={(event) => setMemberSearch(event.target.value)}
+ />
-
-
Description
-
{project.description || "-"}
-
-
- The display name and description may be publicly visible to the users of your app.
-
+
+ {filteredAccessRows.length === 0 ? (
+
+ {accessRows.length === 0 ? "This team has no members yet." : "No members match your search."}
+
+ ) : (
+
row.id}
+ totalRowCount={gridData.totalRowCount}
+ isLoading={gridData.isLoading}
+ state={gridState}
+ onChange={setGridState}
+ toolbar={false}
+ maxHeight={360}
+ />
+ )}
+
+
+
+
+
+
Transfer Project Ownership
+
+ Everyone in this team can access and manage the project.
+
+
+
+ Current Team:
+
+
+
+
+ {!hasAdminPermissionForCurrentTeam ? (
+
+ {`You need to be a team admin of "${currentOwnerTeam.displayName || "the current team"}" to transfer this project.`}
+
+ ) : (
+
+
+
Transfer to a different team:
+
+
+
+ Transfer
+
+ }
+ title="Transfer Project"
+ okButton={{
+ label: "Transfer Project",
+ onClick: handleTransfer,
+ }}
+ cancelButton
+ >
+
+ {`Are you sure you want to transfer "${project.displayName}" to ${selectedTeam?.displayName}?`}
+
+
+ This will change the ownership of the project. Only team admins of the new team will be able to manage project settings.
+
+
+
+ )}
+
+
+
+
+
Production readiness
+
+
+ Ready
+
+
+ Not ready
+
+
+
+
+
+
+
+
+ {isProductionReady ? (
+
+ ) : (
+
+
+ {productionModeErrors.map((error) => (
+
+ {error.message} (show configuration )
+
+ ))}
+
+
+ )}
+
+
+
+
+
+
+
+
+ This action cannot be undone.
+
+
+ Delete Project
+
+ }
+ title="Delete Project"
+ danger
+ okButton={{
+ label: "Delete Project",
+ onClick: handleProjectDelete,
+ }}
+ cancelButton
+ confirmText="I understand this action is IRREVERSIBLE and will delete ALL associated data."
+ >
+
+ {`Are you sure that you want to delete the project with name "${project.displayName}" and ID "${project.id}"?`}
+
+
+ This action is irreversible and will permanently delete:
+
+
+ All users and their data
+ All teams and team memberships
+ All API keys
+ All project configurations
+ All OAuth provider settings
+
+
+
+
+
+
+
-
+ Done
+
+ )}
>
-
+
-
-
{
await project.update({ logoDarkModeUrl });
}}
- description="Upload a dark mode version of your logo. Recommended size: 200x200px"
+ description="Recommended size: 200x200px"
type="logo"
/>
-
{
await project.update({ logoFullDarkModeUrl });
}}
- description="Upload a dark mode version of your full logo. Recommended size: At least 100px tall, landscape format"
+ description="Recommended size: at least 100px tall, landscape"
type="full-logo"
/>
-
-
- Logo images will be displayed in your application (e.g. login page) and emails. The logo should be a square image, while the full logo can include text and be wider.
-
-
+
-
-
-
-
- {currentOwnerTeam.displayName || "Unnamed team"}
-
-
- Everyone in this team can access and manage the project.
-
-
-
-
-
-
- Team members
-
-
-
- Manage team members
-
-
-
- {currentTeamMembers.length === 0 ? (
-
-
- This team has no members yet.
-
-
- ) : (
-
-
- {currentTeamMembers.map((member) => (
-
- ))}
-
-
- )}
-
- Invite new people or adjust roles in the team settings page.
-
-
-
-
-
- Transfer to a different team
-
- {!hasAdminPermissionForCurrentTeam ? (
-
- {`You need to be a team admin of "${currentOwnerTeam.displayName || 'the current team'}" to transfer this project.`}
-
- ) : (
-
-
-
- Transfer
-
- }
- title="Transfer Project"
- okButton={{
- label: "Transfer Project",
- onClick: handleTransfer
- }}
- cancelButton
- >
-
- {`Are you sure you want to transfer "${project.displayName}" to ${selectedTeam?.displayName}?`}
-
-
- This will change the ownership of the project. Only team admins of the new team will be able to manage project settings.
-
-
-
- )}
-
-
-
-
-
-
-
-
- {productionModeErrors.length === 0 ? (
-
- ) : (
-
-
- Please fix the following issues:
-
-
- {productionModeErrors.map((error) => (
-
- {error.message} (show configuration )
-
- ))}
-
-
- )}
-
-
-
-
- {isLoadingSource ? (
- Loading...
- ) : configSource?.type === "unlinked" ? (
-
-
Dashboard
-
- Your configuration is managed directly on this dashboard. Changes take effect immediately when saved.
-
-
- ) : configSource?.type === "pushed-from-github" ? (
-
-
-
GitHub
-
- Your configuration is managed via GitHub. Changes made on this dashboard will be overwritten when you push from GitHub again.
-
-
-
Repository: {configSource.owner}/{configSource.repo}
-
Branch: {configSource.branch}
-
Config file: {configSource.configFilePath}
- {configSource.workflowPath ? (
-
- ) : null}
-
Last commit: {configSource.commitHash.substring(0, 7)}
-
-
-
-
- Unlink from GitHub
-
- }
- title="Unlink Configuration Source"
- okButton={{
- label: "Unlink",
- onClick: handleUnlinkSource,
- }}
- cancelButton
- >
-
- Are you sure you want to unlink your configuration from GitHub?
-
-
- After unlinking, you can edit the configuration directly on this dashboard. However, pushing from GitHub will no longer update your configuration until you reconnect.
-
-
-
-
- ) : configSource?.type === "pushed-from-unknown" ? (
-
-
-
CLI
-
- Your configuration was pushed via the Hexclave CLI. Changes made on this dashboard will be overwritten when you push from the CLI again.
-
-
-
-
- Unlink from CLI
-
- }
- title="Unlink Configuration Source"
- okButton={{
- label: "Unlink",
- onClick: handleUnlinkSource,
- }}
- cancelButton
- >
-
- Are you sure you want to unlink your configuration from the CLI?
-
-
- After unlinking, you can edit the configuration directly on this dashboard. However, pushing from the CLI will no longer update your configuration until you reconnect.
-
-
-
-
- ) : (
- Unknown configuration source
- )}
-
-
-
-
-
-
- Once you delete a project, there is no going back. All data will be permanently removed.
-
-
- Delete Project
-
- }
- title="Delete Project"
- danger
- okButton={{
- label: "Delete Project",
- onClick: handleProjectDelete
- }}
- cancelButton
- confirmText="I understand this action is IRREVERSIBLE and will delete ALL associated data."
- >
-
- {`Are you sure that you want to delete the project with name "${project.displayName}" and ID "${project.id}"?`}
-
-
- This action is irreversible and will permanently delete:
-
-
- All users and their data
- All teams and team memberships
- All API keys
- All project configurations
- All OAuth provider settings
-
-
-
-
-
+
+ Are you sure you want to unlink this configuration source?
+
+
+ After unlinking, you can edit the configuration directly on this dashboard. Future pushes will not update it until you reconnect.
+
+
);
}