Added project settings & fixed api key generation bug (#38)

* added project setting page, restructured tooltip

* removed danger zone

* updated page structures, moved environments to project setting

* renamed pages and page-clients

* fixed meta data, added emails page

* fixed api key projectId bug
This commit is contained in:
Zai Shi 2024-05-17 16:08:07 +02:00 committed by GitHub
parent 393c0a2721
commit 8848adf5cb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 194 additions and 100 deletions

View File

@ -13,7 +13,7 @@ import { ActionDialog } from "@/components/action-dialog";
import Typography from "@/components/ui/typography";
export default function ApiKeysDashboardClient() {
export default function PageClient() {
const stackAdminApp = useAdminApp();
const apiKeySets = stackAdminApp.useApiKeySets();
@ -100,6 +100,8 @@ function ShowKeyDialog(props: {
apiKey?: ApiKeySetFirstView,
onClose?: () => void,
}) {
const stackAdminApp = useAdminApp();
const project = stackAdminApp.useProject();
if (!props.apiKey) return null;
return <ActionDialog
@ -114,7 +116,7 @@ function ShowKeyDialog(props: {
Here are your API keys. Copy them to a safe place. You will not be able to view them again.
</Typography>
<EnvKeys
projectId="projectId"
projectId={project.id}
publishableClientKey={props.apiKey.publishableClientKey}
secretServerKey={props.apiKey.secretServerKey}
/>

View File

@ -1,11 +1,11 @@
import ApiKeysDashboardClient from "./page-client";
import PageClient from "./page-client";
export const metadata = {
title: "API Keys",
};
export default function ApiKeysDashboard() {
export default function Page() {
return (
<ApiKeysDashboardClient />
<PageClient />
);
}

View File

@ -5,7 +5,7 @@ import { OAuthProviderConfigJson } from "@stackframe/stack-shared";
import { PageLayout } from "../page-layout";
import { SettingCard, SettingSwitch } from "@/components/settings";
export default function ProvidersClient() {
export default function PageClient() {
const stackAdminApp = useAdminApp();
const project = stackAdminApp.useProjectAdmin();
const oauthProviders = project.evaluatedConfig.oauthProviders;

View File

@ -1,11 +1,11 @@
import ProvidersClient from "./page-client";
import PageClient from "./page-client";
export const metadata = {
title: "Providers",
title: "Auth Methods",
};
export default function Providers() {
export default function Page() {
return (
<ProvidersClient />
<PageClient />
);
}

View File

@ -16,7 +16,7 @@ import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises"
import Typography from "@/components/ui/typography";
import { InputField, SwitchField } from "@/components/form-fields";
import { FormDialog } from "@/components/form-dialog";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { TextTooltip } from "@/components/text-tooltip";
/**
* All the different types of OAuth providers that can be created.
@ -174,16 +174,9 @@ export function ProviderSettingSwitch(props: Props) {
<div className="flex items-center gap-2">
{toTitle(props.id)}
{isShared && enabled &&
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="secondary">Shared keys</Badge>
</TooltipTrigger>
<TooltipContent>
Shared keys are created by the Stack team for easy development experience
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TextTooltip text="Shared keys are created by the Stack team for easy development experience">
<Badge variant="secondary">Shared keys</Badge>
</TextTooltip>
}
</div>
}

View File

@ -145,7 +145,7 @@ function DeleteDialog(props: {
);
}
export default function UrlsAndCallbacksClient() {
export default function PageClient() {
const stackAdminApp = useAdminApp();
const project = stackAdminApp.useProjectAdmin();
const domains = project.evaluatedConfig.domains;

View File

@ -0,0 +1,11 @@
import PageClient from "./page-client";
export const metadata = {
title: "Domains & Handlers",
};
export default function Page() {
return (
<PageClient />
);
}

View File

@ -1,11 +0,0 @@
import EnvironmentClient from "./page-client";
export const metadata = {
title: "Environment",
};
export default function Environment() {
return (
<EnvironmentClient />
);
}

View File

@ -1,19 +1,17 @@
"use client";;
import { useAdminApp } from "../use-admin-app";
import { PageLayout } from "../page-layout";
import { SettingCard, SettingSwitch } from "@/components/settings";
import { SettingCard, SettingInput, SettingSwitch } from "@/components/settings";
import { Alert } from "@/components/ui/alert";
import Typography from "@/components/ui/typography";
import { Link } from "@/components/link";
export default function EnvironmentClient() {
export default function PageClient() {
const stackAdminApp = useAdminApp();
const project = stackAdminApp.useProjectAdmin();
const productionModeErrors = project.getProductionModeErrors();
return (
<PageLayout title="Environment" description="Development and production settings">
<PageLayout title="Project Settings" description="Manage your project">
<SettingCard title="Production mode" description="Production mode disallows certain configuration options that are useful for development but deemed unsafe for production usage. To prevent accidental misconfigurations it is strongly recommended to enable production mode on your production environments.">
<SettingSwitch
label="Enable production mode"
@ -43,6 +41,35 @@ export default function EnvironmentClient() {
</Alert>
)}
</SettingCard>
<SettingCard title="Project Information">
<SettingInput
label="Display Name"
onChange={(v) => project.update({ displayName: v })}
defaultValue={project.displayName}/>
<SettingInput
label="Description"
onChange={(v) => project.update({ description: v })}
defaultValue={project.description}
/>
</SettingCard>
{/* <SettingCard title="Danger Zone" description="Be careful with these settings" accordion="Danger Settings">
<div>
<ActionDialog
danger
title="Delete Project"
trigger={<Button variant="destructive">Delete Project</Button>}
okButton={{ label: "Delete Project", onClick: async () => {
// await project.delete();
// stackAdminApp.router.push("/projects");
}}}
confirmText="I understand that all the users, teams, and data associated with this project will be permanently deleted. This action cannot be undone."
>
{`Are you sure that you want to delete the project "${project.displayName}" with the id of "${project.id}"?`}
</ActionDialog>
</div>
</SettingCard> */}
</PageLayout>
);
}

View File

@ -0,0 +1,11 @@
import PageClient from "./page-client";
export const metadata = {
title: "Project Settings",
};
export default function Page() {
return (
<PageClient />
);
}

View File

@ -1,3 +0,0 @@
import { redirectHandler } from "@/route-handlers/redirect-handler";
export const GET = redirectHandler("api-keys");

View File

@ -2,11 +2,12 @@
import Link from "next/link";
import {
Book,
Globe,
KeyRound,
LockKeyhole,
LucideIcon,
Mail,
Menu,
Settings,
Settings2,
ShieldEllipsis,
User,
@ -105,18 +106,11 @@ const navigationItems: (Label | Item | Hidden)[] = [
},
{
name: "Domains & Handlers",
href: "/urls-and-callbacks",
regex: /^\/projects\/[^\/]+\/urls-and-callbacks$/,
href: "/domains",
regex: /^\/projects\/[^\/]+\/domains$/,
icon: LinkIcon,
type: 'item'
},
{
name: "Environment",
href: "/environment",
regex: /^\/projects\/[^\/]+\/environment$/,
icon: Globe,
type: 'item'
},
{
name: "API Keys",
href: "/api-keys",
@ -124,6 +118,13 @@ const navigationItems: (Label | Item | Hidden)[] = [
icon: KeyRound,
type: 'item'
},
{
name: "Project Settings",
href: "/project-settings",
regex: /^\/projects\/[^\/]+\/project-settings$/,
icon: Settings,
type: 'item'
}
];
export function NavItem({ item, href, onClick }: { item: Item, href: string, onClick?: () => void}) {

View File

@ -10,7 +10,7 @@ import { InputField } from "@/components/form-fields";
import { TeamPermissionTable } from "@/components/data-table/team-permission-table";
export default function ClientPage() {
export default function PageClient() {
const stackAdminApp = useAdminApp();
const permissions = stackAdminApp.usePermissionDefinitions();
const [createPermissionModalOpen, setCreatePermissionModalOpen] = React.useState(false);

View File

@ -1,11 +1,11 @@
import ClientPage from "./page-client";
import PageClient from "./page-client";
export const metadata = {
title: "Team Permissions",
};
export default function TeamPermissions() {
export default function Page() {
return (
<ClientPage />
<PageClient />
);
}

View File

@ -3,7 +3,7 @@ import { useAdminApp } from "../use-admin-app";
import { PageLayout } from "../page-layout";
import { SettingCard, SettingSwitch } from "@/components/settings";
export default function TeamSettingsClient() {
export default function PageClient() {
const stackAdminApp = useAdminApp();
const project = stackAdminApp.useProjectAdmin();

View File

@ -1,11 +1,11 @@
import TeamSettingsClient from "./page-client";
import PageClient from "./page-client";
export const metadata = {
title: "Team Settings",
};
export default function TeamSettings() {
export default function Page() {
return (
<TeamSettingsClient />
<PageClient />
);
}

View File

@ -5,7 +5,7 @@ import { PageLayout } from '../../page-layout';
import { TeamMemberTable } from '@/components/data-table/team-member-table';
export default function ClientPage(props: { teamId: string }) {
export default function PageClient(props: { teamId: string }) {
const stackAdminApp = useAdminApp();
const team = stackAdminApp.useTeam(props.teamId);
const users = team?.useMembers();

View File

@ -1,4 +1,4 @@
import ClientPage from "./page-client";
import PageClient from "./page-client";
export const metadata = {
title: "Team Members",
@ -6,6 +6,6 @@ export const metadata = {
export default function Page({ params }: { params: { teamId: string } }) {
return (
<ClientPage teamId={params.teamId} />
<PageClient teamId={params.teamId} />
);
}

View File

@ -4,7 +4,7 @@ import { PageLayout } from "../page-layout";
import { TeamTable } from "@/components/data-table/team-table";
export default function ClientPage() {
export default function PageClient() {
const stackAdminApp = useAdminApp();
const teams = stackAdminApp.useTeams();

View File

@ -1,11 +1,11 @@
import ClientPage from "./page-client";
import PageClient from "./page-client";
export const metadata = {
title: "Teams",
};
export default function TeamDashboard() {
export default function Page() {
return (
<ClientPage />
<PageClient />
);
}

View File

@ -1,11 +0,0 @@
import UrlsAndCallbacksClient from "./page-client";
export const metadata = {
title: "Domains & Handlers",
};
export default function UrlsAndCallbacks() {
return (
<UrlsAndCallbacksClient />
);
}

View File

@ -7,7 +7,7 @@ import { Link } from "@/components/link";
import { UserTable } from "@/components/data-table/user-table";
export default function UsersDashboardClient() {
export default function PageClient() {
const stackAdminApp = useAdminApp();
const allUsers = stackAdminApp.useServerUsers();

View File

@ -1,11 +1,11 @@
import UsersDashboardClient from "./page-client";
import PageClient from "./page-client";
export const metadata = {
title: "Users",
};
export default function UsersDashboard() {
export default function Page() {
return (
<UsersDashboardClient />
<PageClient />
);
}

View File

@ -15,7 +15,7 @@ import { ActionDialog } from "../action-dialog";
import Typography from "../ui/typography";
import { standardFilterFn } from "./elements/utils";
import { CircleAlert } from "lucide-react";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip";
import { TextTooltip } from "../text-tooltip";
export type ExtendedServerUser = ServerUser & {
authType: string,
@ -155,17 +155,9 @@ export const commonUserColumns: ColumnDef<ExtendedServerUser>[] = [
{
accessorKey: "primaryEmail",
header: ({ column }) => <DataTableColumnHeader column={column} title="Primary Email" />,
cell: ({ row }) => <TextCell size={180} icon={row.original.emailVerified === "unverified" &&
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<CircleAlert className="text-zinc-500 h-4 w-4" />
</TooltipTrigger>
<TooltipContent>
Email not verified
</TooltipContent>
</Tooltip>
</TooltipProvider>}>
cell: ({ row }) => <TextCell
size={180}
icon={row.original.emailVerified === "unverified" && <TextTooltip text="Email not verified"><CircleAlert className="text-zinc-500 h-4 w-4" /></TextTooltip>}>
{row.original.primaryEmail}
</TextCell>,
},

View File

@ -9,8 +9,12 @@ import {
import { Switch } from "./ui/switch";
import { Settings } from "lucide-react";
import { Button } from "./ui/button";
import { useId, useState } from "react";
import React, { useEffect, useId, useRef, useState } from "react";
import { Label } from "./ui/label";
import { DelayedInput, Input } from "./ui/input";
import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises";
import { Accordion } from "@radix-ui/react-accordion";
import { AccordionContent, AccordionItem, AccordionTrigger } from "./ui/accordion";
export function SettingCard(props: {
@ -18,6 +22,7 @@ export function SettingCard(props: {
description?: string,
actions?: React.ReactNode,
children?: React.ReactNode,
accordion?: string,
}) {
return (
<Card>
@ -25,8 +30,19 @@ export function SettingCard(props: {
<CardTitle>{props.title}</CardTitle>
{props.description && <CardDescription>{props.description}</CardDescription>}
</CardHeader>
<CardContent className="flex flex-col gap-4">
{props.children}
{props.accordion ?
<Accordion type="single" collapsible className="w-full">
<AccordionItem value="item-1">
<AccordionTrigger>{props.accordion}</AccordionTrigger>
<AccordionContent>
{props.children}
</AccordionContent>
</AccordionItem>
</Accordion> :
props.children}
</CardContent>
{props.actions && <CardFooter>
<div className="w-full flex justify-end">
@ -69,12 +85,33 @@ export function SettingSwitch(props: {
);
}
export function SettingIconButton(props: {
onClick?: () => void | Promise<void>,
}) {
export const SettingIconButton = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>((props, ref) => {
return (
<Button variant='ghost' size='sm' className="p-1 h-full" onClick={props.onClick}>
<Button variant='ghost' size='sm' className="p-1 h-full" onClick={props.onClick} ref={ref}>
<Settings className="w-4 h-4 text-muted-foreground" />
</Button>
);
}
});
SettingIconButton.displayName = "SettingIconButton";
export function SettingInput(props: {
label: string,
defaultValue?: string,
onChange: (value: string) => void | Promise<void>,
actions?: React.ReactNode,
}) {
return (
<div className="flex flex-col gap-2">
<Label>{props.label}</Label>
<DelayedInput
className="max-w-[400px]"
defaultValue={props.defaultValue}
onChange={(e) => runAsynchronously(props.onChange(e.target.value))}
/>
{props.actions}
</div>
);
}

View File

@ -0,0 +1,19 @@
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip";
export function TextTooltip(props: {
text: string,
children: React.ReactNode,
}) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
{props.children}
</TooltipTrigger>
<TooltipContent>
{props.text}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}

View File

@ -23,3 +23,29 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
Input.displayName = "Input";
export { Input };
export interface DelayedInputProps extends InputProps {
delay?: number,
}
export const DelayedInput = React.forwardRef<HTMLInputElement, DelayedInputProps>(
({ delay = 500, defaultValue, ...props }, ref) => {
const [value, setValue] = React.useState(defaultValue ?? "");
const timeout = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setValue(e.target.value);
if (timeout.current) {
clearTimeout(timeout.current);
}
timeout.current = setTimeout(() => {
props.onChange?.(e);
}, delay);
};
return <Input ref={ref} {...props} value={value} onChange={onChange} />;
}
);
DelayedInput.displayName = "DelayedInput";