Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,053 changes: 1,053 additions & 0 deletions apps/dashboard/messages/en.json

Large diffs are not rendered by default.

1,053 changes: 1,053 additions & 0 deletions apps/dashboard/messages/zh.json

Large diffs are not rendered by default.

7 changes: 6 additions & 1 deletion apps/dashboard/next.config.mjs
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import { withSentryConfig } from "@sentry/nextjs";

import createBundleAnalyzer from "@next/bundle-analyzer";
import createNextIntlPlugin from 'next-intl/plugin';

const withBundleAnalyzer = createBundleAnalyzer({
enabled: !!process.env.ANALYZE_BUNDLE,
});

const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts');

const withConfiguredSentryConfig = (nextConfig) =>
withSentryConfig(
nextConfig,
Expand Down Expand Up @@ -128,5 +131,7 @@ const nextConfig = {
};

export default withConfiguredSentryConfig(
withBundleAnalyzer(nextConfig)
withBundleAnalyzer(
withNextIntl(nextConfig)
)
);
1 change: 1 addition & 0 deletions apps/dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
"lodash": "^4.17.21",
"lucide-react": "^0.508.0",
"next": "15.4.1",
"next-intl": "^4.3.11",
"next-themes": "^0.2.1",
"posthog-js": "^1.235.0",
"react": "19.0.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,29 +5,35 @@ import { yupResolver } from "@hookform/resolvers/yup";
import { AuthPage, TeamSwitcher, useUser } from "@stackframe/stack";
import { allProviders } from "@stackframe/stack-shared/dist/utils/oauth";
import { runAsynchronouslyWithAlert, wait } from "@stackframe/stack-shared/dist/utils/promises";
import { BrowserFrame, Button, Form, FormControl, FormField, FormItem, FormMessage, Separator, Typography } from "@stackframe/stack-ui";
import { BrowserFrame, Button, Form, FormControl, FormField, FormItem, FormMessage, Typography } from "@stackframe/stack-ui";
import { useTranslations } from 'next-intl';
import { useSearchParams } from "next/navigation";
import { useState } from "react";
import { useForm } from "react-hook-form";
import * as yup from "yup";

export const projectFormSchema = yup.object({
displayName: yup.string().min(1, "Display name is required").defined().nonEmpty("Display name is required"),
signInMethods: yup.array(yup.string().oneOf(["credential", "magicLink", "passkey"].concat(allProviders)).defined())
.min(1, "At least one sign-in method is required")
.defined("At least one sign-in method is required"),
teamId: yup.string().uuid().defined("Team is required"),
});

type ProjectFormValues = yup.InferType<typeof projectFormSchema>
type ProjectFormValues = {
displayName: string,
signInMethods: string[],
teamId: string,
};

export default function PageClient() {
const t = useTranslations('newProject');
const user = useUser({ or: 'redirect', projectIdMustMatch: "internal" });
const [loading, setLoading] = useState(false);
const router = useRouter();
const searchParams = useSearchParams();
const displayName = searchParams.get("display_name");

const projectFormSchema = yup.object({
displayName: yup.string().min(1, t('validation.displayNameRequired')).defined().nonEmpty(t('validation.displayNameRequired')),
signInMethods: yup.array(yup.string().oneOf(["credential", "magicLink", "passkey"].concat(allProviders)).defined())
.min(1, t('validation.signInMethodRequired'))
.defined(t('validation.signInMethodRequired')),
teamId: yup.string().uuid().defined(t('validation.teamRequired')),
});

const defaultValues: Partial<ProjectFormValues> = {
displayName: displayName || "",
signInMethods: ["credential", "google", "github"],
Expand Down Expand Up @@ -92,21 +98,21 @@ export default function PageClient() {
<div className="w-full md:w-1/2 p-4">
<div className='max-w-xs m-auto'>
<div className="flex justify-center mb-4">
<Typography type='h2'>Create a new project</Typography>
<Typography type='h2'>{t('title')}</Typography>
</div>

<Form {...form}>
<form onSubmit={e => runAsynchronouslyWithAlert(form.handleSubmit(onSubmit)(e))} className="space-y-4">

<InputField required control={form.control} name="displayName" label="Display Name" placeholder="My Project" />
<InputField required control={form.control} name="displayName" label={t('fields.displayName')} placeholder={t('fields.displayNamePlaceholder')} />

<FormField
control={form.control}
name="teamId"
render={({ field }) => (
<FormItem>
<label className="flex flex-col gap-2">
<FieldLabel required={true}>Team</FieldLabel>
<FieldLabel required={true}>{t('fields.team')}</FieldLabel>
<FormControl>
<TeamSwitcher
triggerClassName="max-w-full"
Expand All @@ -125,21 +131,21 @@ export default function PageClient() {
<SwitchListField
control={form.control}
name="signInMethods"
label="Sign-In Methods"
label={t('fields.signInMethods')}
options={[
{ value: "credential", label: "Email password" },
{ value: "magicLink", label: "Magic link/OTP" },
{ value: "passkey", label: "Passkey" },
{ value: "credential", label: t('signInOptions.emailPassword') },
{ value: "magicLink", label: t('signInOptions.magicLink') },
{ value: "passkey", label: t('signInOptions.passkey') },
{ value: "google", label: "Google" },
{ value: "github", label: "GitHub" },
{ value: "microsoft", label: "Microsoft" },
]}
info="More sign-in methods are available on the dashboard later."
info={t('signInMethodsInfo')}
/>

<div className="flex justify-center">
<Button loading={loading} type="submit">
{redirectToNeonConfirmWith ? "Create & Connect Project" : "Create Project"}
{redirectToNeonConfirmWith ? t('buttons.createAndConnect') : t('buttons.create')}
</Button>
</div>
</form>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,13 @@ import { wait } from "@stackframe/stack-shared/dist/utils/promises";
import { stringCompare } from "@stackframe/stack-shared/dist/utils/strings";
import { Button, Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue, Typography, toast } from "@stackframe/stack-ui";
import { UserPlus } from "lucide-react";
import { useTranslations } from 'next-intl';
import { Suspense, useEffect, useMemo, useState } from "react";
import * as yup from "yup";


export default function PageClient(props: { inviteUser: (origin: string, teamId: string, email: string) => Promise<void> }) {
const t = useTranslations('projects');
const user = useUser({ or: 'redirect', projectIdMustMatch: "internal" });
const rawProjects = user.useOwnedProjects();
const teams = user.useTeams();
Expand Down Expand Up @@ -69,19 +71,19 @@ export default function PageClient(props: { inviteUser: (origin: string, teamId:
<div className="flex-grow p-4">
<div className="flex justify-between gap-4 mb-4 flex-col sm:flex-row">
<SearchBar
placeholder="Search project name"
placeholder={t('searchPlaceholder')}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<div className="flex gap-4">
<Select value={sort} onValueChange={(n) => setSort(n === 'recency' ? 'recency' : 'name')}>
<SelectTrigger>
<SelectValue>Sort by {sort === "recency" ? "recency" : "name"}</SelectValue>
<SelectValue>{t('sortBy')} {sort === "recency" ? t('recency') : t('name')}</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="recency">Recency</SelectItem>
<SelectItem value="name">Name</SelectItem>
<SelectItem value="recency">{t('recency')}</SelectItem>
<SelectItem value="name">{t('name')}</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
Expand All @@ -91,7 +93,7 @@ export default function PageClient(props: { inviteUser: (origin: string, teamId:
router.push('/new-project');
return await wait(2000);
}}
>Create Project
>{t('createProject')}
</Button>
</div>
</div>
Expand All @@ -107,7 +109,7 @@ export default function PageClient(props: { inviteUser: (origin: string, teamId:
/>
</Suspense>
)}
{teamId ? teamIdMap.get(teamId) : "No Team"}
{teamId ? teamIdMap.get(teamId) : t('noTeam')}
</Typography>
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4">
{projects.map((project) => (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import useResizeObserver from '@react-hook/resize-observer';
import { useUser } from '@stackframe/stack';
import { getFlagEmoji } from '@stackframe/stack-shared/dist/utils/unicode';
import { Typography } from '@stackframe/stack-ui';
import { useTranslations } from 'next-intl';
import dynamic from 'next/dynamic';
import { RefObject, use, useEffect, useId, useLayoutEffect, useRef, useState } from 'react';
import { GlobeMethods } from 'react-globe.gl';
Expand All @@ -29,6 +30,7 @@ function useSize(target: RefObject<HTMLDivElement | null>) {
}

export function GlobeSection({ countryData, totalUsers, children }: {countryData: Record<string, number>, totalUsers: number, children?: React.ReactNode}) {
const t = useTranslations('overview.globe');
const countries = use(countriesPromise);
const globeRef = useRef<GlobeMethods | undefined>(undefined);

Expand Down Expand Up @@ -273,12 +275,12 @@ export function GlobeSection({ countryData, totalUsers, children }: {countryData
>
<div className='absolute top-0 left-0 right-0 bottom-0 lg:hidden block'>
<Typography type="h2">
Welcome back!
{t('welcomeBack')}!
</Typography>
</div>
<div className='absolute top-1 left-2 right-0 bottom-0 flex flex-col gap-4 hidden lg:block'>
<Typography type="h2">
Welcome back{displayName ? `, ${displayName}!` : '!'}
{t('welcomeBack')}{displayName ? `, ${displayName}!` : '!'}
</Typography>
<div className='text-red-500 p-4 flex items-center gap-1.5 text-xs font-bold'>
<div className="stack-live-pulse" />
Expand All @@ -296,24 +298,24 @@ export function GlobeSection({ countryData, totalUsers, children }: {countryData
100% {box-shadow: 0 0 0 8px #0000}
}
`}</style>
LIVE
{t('live')}
</div>
</div>
</div>
<div className='relative h-full flex-grow flex flex-col gap-4 z-1'>
<Typography type='h2' className='text-sm uppercase'>
🌎 Worldwide
{t('worldwide')}
</Typography>
<Typography type='p' className='text-2xl'>
{totalUsers} total users
{totalUsers} {t('totalUsers')}
</Typography>
{selectedCountry && (
<>
<Typography type='h2' className='text-sm uppercase mt-6'>
{selectedCountry.code.match(/^[a-zA-Z][a-zA-Z]$/) ? `${getFlagEmoji(selectedCountry.code)} ` : ""} {selectedCountry.name}
</Typography>
<Typography type='p' className='text-2xl'>
{countryData[selectedCountry.code] ?? 0} users
{countryData[selectedCountry.code] ?? 0} {t('users')}
</Typography>
</>
)}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart";
import { isWeekend } from "@stackframe/stack-shared/dist/utils/dates";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@stackframe/stack-ui";
import { useTranslations } from "next-intl";
import { Bar, BarChart, CartesianGrid, Cell, Pie, PieChart, XAxis, YAxis } from "recharts";

export type LineChartDisplayConfig = {
Expand Down Expand Up @@ -149,11 +150,13 @@ export function DonutChartDisplay({
}: {
datapoints: AuthMethodDatapoint[],
}) {
const t = useTranslations('overview.metrics');

return (
<Card>
<CardHeader>
<CardTitle>
Auth Methods
{t('charts.authMethods')}
</CardTitle>
</CardHeader>
<CardContent>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { ErrorBoundary } from '@sentry/nextjs';
import { UserAvatar } from '@stackframe/stack';
import { fromNow } from '@stackframe/stack-shared/dist/utils/dates';
import { Card, CardContent, CardHeader, CardTitle, Table, TableBody, TableCell, TableRow, Typography } from '@stackframe/stack-ui';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
import { PageLayout } from "../page-layout";
import { useAdminApp } from '../use-admin-app';
Expand All @@ -14,38 +15,39 @@ import { DonutChartDisplay, LineChartDisplay, LineChartDisplayConfig } from './l

const stackAppInternalsSymbol = Symbol.for("StackAuth--DO-NOT-USE-OR-YOU-WILL-BE-FIRED--StackAppInternals");

const dailySignUpsConfig = {
name: 'Daily Sign-ups',
description: 'User registration over the last 30 days',
chart: {
activity: {
label: "Activity",
color: "#cc6ce7",
},
}
} satisfies LineChartDisplayConfig;

const dauConfig = {
name: 'Daily Active Users',
description: 'Number of unique users that were active over the last 30 days',
chart: {
activity: {
label: "Activity",
color: "#2563eb",
},
}
} satisfies LineChartDisplayConfig;

export default function MetricsPage(props: { toSetup: () => void }) {
const t = useTranslations('overview.metrics');
const adminApp = useAdminApp();
const router = useRouter();
const [includeAnonymous, setIncludeAnonymous] = useState(false);

const data = (adminApp as any)[stackAppInternalsSymbol].useMetrics(includeAnonymous);

const dailySignUpsConfig = {
name: t('dailySignUps.title'),
description: t('dailySignUps.description'),
chart: {
activity: {
label: t('charts.activity'),
color: "#cc6ce7",
},
}
} satisfies LineChartDisplayConfig;

const dauConfig = {
name: t('dailyActiveUsers.title'),
description: t('dailyActiveUsers.description'),
chart: {
activity: {
label: t('charts.activity'),
color: "#2563eb",
},
}
} satisfies LineChartDisplayConfig;

return (
<PageLayout fillWidth>
<ErrorBoundary fallback={<div className='text-center text-sm text-red-500'>Error initializing globe visualization. Please try updating your browser or enabling WebGL.</div>}>
<ErrorBoundary fallback={<div className='text-center text-sm text-red-500'>{t('globe.errorMessage', { ns: 'overview' })}</div>}>
<GlobeSection countryData={data.users_by_country} totalUsers={data.total_users} />
</ErrorBoundary>
<div className='grid gap-4 lg:grid-cols-2'>
Expand All @@ -59,11 +61,11 @@ export default function MetricsPage(props: { toSetup: () => void }) {
/>
<Card>
<CardHeader>
<CardTitle>Recent Sign Ups</CardTitle>
<CardTitle>{t('recentSignUps.title')}</CardTitle>
</CardHeader>
<CardContent>
{data.recently_registered.length === 0 && (
<Typography variant='secondary'>No recent sign ups</Typography>
<Typography variant='secondary'>{t('recentSignUps.empty')}</Typography>
)}
<Table>
<TableBody>
Expand All @@ -78,7 +80,7 @@ export default function MetricsPage(props: { toSetup: () => void }) {
<TableCell>
{user.display_name ?? user.primary_email}
<Typography variant='secondary'>
signed up {fromNow(new Date(user.signed_up_at_millis))}
{t('recentSignUps.signedUp')} {fromNow(new Date(user.signed_up_at_millis))}
</Typography>
</TableCell>
</TableRow>
Expand All @@ -89,11 +91,11 @@ export default function MetricsPage(props: { toSetup: () => void }) {
</Card>
<Card>
<CardHeader>
<CardTitle>Recently Active Users</CardTitle>
<CardTitle>{t('recentlyActive.title')}</CardTitle>
</CardHeader>
<CardContent>
{data.recently_active.length === 0 && (
<Typography variant='secondary'>No recent active users</Typography>
<Typography variant='secondary'>{t('recentlyActive.empty')}</Typography>
)}
<Table>
<TableBody>
Expand All @@ -108,7 +110,7 @@ export default function MetricsPage(props: { toSetup: () => void }) {
<TableCell>
{user.display_name ?? user.primary_email}
<Typography variant='secondary'>
last active {fromNow(new Date(user.last_active_at_millis))}
{t('recentlyActive.lastActive')} {fromNow(new Date(user.last_active_at_millis))}
</Typography>
</TableCell>
</TableRow>
Expand Down
Loading