From 437107aa7b189c9011ffe3819bab50331ca17d71 Mon Sep 17 00:00:00 2001 From: Developing-Gamer Date: Sat, 18 Jul 2026 21:15:18 -0700 Subject: [PATCH] initial version --- .../project-settings/page-client.tsx | 971 +++++++++++------- 1 file changed, 577 insertions(+), 394 deletions(-) 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)}> + Manage Logo + +
    + +
    +
    +
    +

    {project.displayName}

    + setIsProjectDetailsDialogOpen(true)} + aria-label="Edit project details" + > + + +
    +

    + {project.description || "No project description has been added."} +

    +
    + +
    +

    Project ID

    + +
    +
    + - 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 - +
    + + + {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 ? ( -
    - Workflow file:{" "} - - {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. +

    + ); }