move all components to components dir

This commit is contained in:
miloschwartz
2025-09-04 11:18:42 -07:00
parent 4292d3262e
commit df85f13aea
90 changed files with 2166 additions and 86 deletions

View File

@@ -0,0 +1,48 @@
"use client";
import { HorizontalTabs } from "@app/components/HorizontalTabs";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { useTranslations } from "next-intl";
interface AccessPageHeaderAndNavProps {
children: React.ReactNode;
hasInvitations: boolean;
}
export default function AccessPageHeaderAndNav({
children,
hasInvitations
}: AccessPageHeaderAndNavProps) {
const t = useTranslations();
const navItems = [
{
title: t('users'),
href: `/{orgId}/settings/access/users`
},
{
title: t('roles'),
href: `/{orgId}/settings/access/roles`
}
];
if (hasInvitations) {
navItems.push({
title: t('invite'),
href: `/{orgId}/settings/access/invitations`
});
}
return (
<>
<SettingsSectionTitle
title={t('accessUsersRoles')}
description={t('accessUsersRolesDescription')}
/>
<HorizontalTabs items={navItems}>
{children}
</HorizontalTabs>
</>
);
}

View File

@@ -0,0 +1,160 @@
"use client";
import { createApiClient } from "@app/lib/api";
import { Button } from "@app/components/ui/button";
import {
Card,
CardContent,
CardHeader,
CardTitle
} from "@app/components/ui/card";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { AuthWithAccessTokenResponse } from "@server/routers/resource";
import { AxiosResponse } from "axios";
import Link from "next/link";
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
type AccessTokenProps = {
token: string;
resourceId?: number;
};
export default function AccessToken({
token,
resourceId
}: AccessTokenProps) {
const [loading, setLoading] = useState(true);
const [isValid, setIsValid] = useState(false);
const { env } = useEnvContext();
const api = createApiClient({ env });
const t = useTranslations();
function appendRequestToken(url: string, token: string) {
const fullUrl = new URL(url);
fullUrl.searchParams.append(
env.server.resourceSessionRequestParam,
token
);
return fullUrl.toString();
}
useEffect(() => {
if (!token) {
setLoading(false);
return;
}
let accessTokenId = "";
let accessToken = "";
const parts = token.split(".");
if (parts.length === 2) {
accessTokenId = parts[0];
accessToken = parts[1];
} else if (parts.length === 1) {
accessToken = parts[0];
} else {
setLoading(false);
return;
}
async function checkSHA256() {
try {
const res = await api.post<
AxiosResponse<AuthWithAccessTokenResponse>
>(`/auth/access-token`, {
accessToken,
accessTokenId
});
if (res.data.data.session) {
setIsValid(true);
window.location.href = appendRequestToken(
res.data.data.redirectUrl!,
res.data.data.session
);
}
} catch (e) {
console.error(t('accessTokenError'), e);
} finally {
setLoading(false);
}
}
async function check() {
try {
const res = await api.post<
AxiosResponse<AuthWithAccessTokenResponse>
>(`/auth/resource/${resourceId}/access-token`, {
accessToken,
accessTokenId
});
if (res.data.data.session) {
setIsValid(true);
window.location.href = appendRequestToken(
res.data.data.redirectUrl!,
res.data.data.session
);
}
} catch (e) {
console.error(t('accessTokenError'), e);
} finally {
setLoading(false);
}
}
if (!accessTokenId) {
// no access token id so check the sha256
checkSHA256();
} else {
check();
}
}, [token]);
function renderTitle() {
if (isValid) {
return t('accessGranted');
} else {
return t('accessUrlInvalid');
}
}
function renderContent() {
if (isValid) {
return (
<div>
{t('accessGrantedDescription')}
</div>
);
} else {
return (
<div>
{t('accessUrlInvalidDescription')}
<div className="text-center mt-4">
<Button>
<Link href="/">{t('goHome')}</Link>
</Button>
</div>
</div>
);
}
}
return loading ? (
<div></div>
) : (
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle className="text-center text-2xl font-bold">
{renderTitle()}
</CardTitle>
</CardHeader>
<CardContent>{renderContent()}</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,102 @@
"use client";
import { useState } from "react";
import { Check, Copy, Info, InfoIcon } from "lucide-react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { useEnvContext } from "@app/hooks/useEnvContext";
import CopyToClipboard from "@app/components/CopyToClipboard";
import CopyTextBox from "@app/components/CopyTextBox";
import { useTranslations } from "next-intl";
interface AccessTokenSectionProps {
token: string;
tokenId: string;
resourceUrl: string;
}
export default function AccessTokenSection({
token,
tokenId,
resourceUrl
}: AccessTokenSectionProps) {
const { env } = useEnvContext();
const [copied, setCopied] = useState<string | null>(null);
const copyToClipboard = (text: string, type: string) => {
navigator.clipboard.writeText(text);
setCopied(type);
setTimeout(() => setCopied(null), 2000);
};
const t = useTranslations();
return (
<>
<div className="flex items-start space-x-2">
<p className="text-sm text-muted-foreground">
{t('shareTokenDescription')}
</p>
</div>
<Tabs defaultValue="token" className="w-full mt-4">
<TabsList className="grid grid-cols-2">
<TabsTrigger value="token">{t('accessToken')}</TabsTrigger>
<TabsTrigger value="usage">{t('usageExamples')}</TabsTrigger>
</TabsList>
<TabsContent value="token" className="space-y-4">
<div className="space-y-1">
<div className="font-bold">{t('tokenId')}</div>
<CopyToClipboard text={tokenId} isLink={false} />
</div>
<div className="space-y-1">
<div className="font-bold">{t('token')}</div>
<CopyToClipboard text={token} isLink={false} />
</div>
</TabsContent>
<TabsContent value="usage" className="space-y-4">
<div className="space-y-2">
<h3 className="text-sm font-medium">{t('requestHeades')}</h3>
<CopyTextBox
text={`${env.server.resourceAccessTokenHeadersId}: ${tokenId}
${env.server.resourceAccessTokenHeadersToken}: ${token}`}
/>
</div>
<div className="space-y-2">
<h3 className="text-sm font-medium">{t('queryParameter')}</h3>
<CopyTextBox
text={`${resourceUrl}?${env.server.resourceAccessTokenParam}=${tokenId}.${token}`}
/>
</div>
<Alert variant="neutral">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
{t('importantNote')}
</AlertTitle>
<AlertDescription>
{t('shareImportantDescription')}
</AlertDescription>
</Alert>
</TabsContent>
</Tabs>
<div className="text-sm text-muted-foreground mt-4">
{t('shareTokenSecurety')}
</div>
</>
);
}

View File

@@ -0,0 +1,34 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { DataTable } from "@app/components/ui/data-table";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
}
export function IdpDataTable<TData, TValue>({
columns,
data
}: DataTableProps<TData, TValue>) {
const router = useRouter();
const t = useTranslations();
return (
<DataTable
columns={columns}
data={data}
persistPageSize="idp-table"
title={t('idp')}
searchPlaceholder={t('idpSearch')}
searchColumn="name"
addButtonText={t('idpAdd')}
onAdd={() => {
router.push("/admin/idp/create");
}}
/>
);
}

View File

@@ -0,0 +1,208 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { IdpDataTable } from "@app/components/AdminIdpDataTable";
import { Button } from "@app/components/ui/button";
import { ArrowRight, ArrowUpDown, MoreHorizontal } from "lucide-react";
import { useState } from "react";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { toast } from "@app/hooks/useToast";
import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { Badge } from "@app/components/ui/badge";
import { useRouter } from "next/navigation";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu";
import Link from "next/link";
import { useTranslations } from "next-intl";
export type IdpRow = {
idpId: number;
name: string;
type: string;
orgCount: number;
};
type Props = {
idps: IdpRow[];
};
export default function IdpTable({ idps }: Props) {
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selectedIdp, setSelectedIdp] = useState<IdpRow | null>(null);
const api = createApiClient(useEnvContext());
const router = useRouter();
const t = useTranslations();
const deleteIdp = async (idpId: number) => {
try {
await api.delete(`/idp/${idpId}`);
toast({
title: t("success"),
description: t("idpDeletedDescription")
});
setIsDeleteModalOpen(false);
router.refresh();
} catch (e) {
toast({
title: t("error"),
description: formatAxiosError(e),
variant: "destructive"
});
}
};
const getTypeDisplay = (type: string) => {
switch (type) {
case "oidc":
return "OAuth2/OIDC";
default:
return type;
}
};
const columns: ColumnDef<IdpRow>[] = [
{
accessorKey: "idpId",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
ID
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "name",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("name")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "type",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("type")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const type = row.original.type;
return (
<Badge variant="secondary">{getTypeDisplay(type)}</Badge>
);
}
},
{
id: "actions",
cell: ({ row }) => {
const siteRow = row.original;
return (
<div className="flex items-center justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">
{t("openMenu")}
</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<Link
className="block w-full"
href={`/admin/idp/${siteRow.idpId}/general`}
>
<DropdownMenuItem>
{t("viewSettings")}
</DropdownMenuItem>
</Link>
<DropdownMenuItem
onClick={() => {
setSelectedIdp(siteRow);
setIsDeleteModalOpen(true);
}}
>
<span className="text-red-500">
{t("delete")}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Link href={`/admin/idp/${siteRow.idpId}/general`}>
<Button
variant={"secondary"}
className="ml-2"
size="sm"
>
{t("edit")}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</Link>
</div>
);
}
}
];
return (
<>
{selectedIdp && (
<ConfirmDeleteDialog
open={isDeleteModalOpen}
setOpen={(val) => {
setIsDeleteModalOpen(val);
setSelectedIdp(null);
}}
dialog={
<div className="space-y-4">
<p>
{t("idpQuestionRemove", {
name: selectedIdp.name
})}
</p>
<p>
<b>{t("idpMessageRemove")}</b>
</p>
<p>{t("idpMessageConfirm")}</p>
</div>
}
buttonText={t("idpConfirmDelete")}
onConfirm={async () => deleteIdp(selectedIdp.idpId)}
string={selectedIdp.name}
title={t("idpDelete")}
/>
)}
<IdpDataTable columns={columns} data={idps} />
</>
);
}

View File

@@ -0,0 +1,31 @@
"use client";
import {
ColumnDef,
} from "@tanstack/react-table";
import { DataTable } from "@app/components/ui/data-table";
import { useTranslations } from "next-intl";
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
}
export function UsersDataTable<TData, TValue>({
columns,
data
}: DataTableProps<TData, TValue>) {
const t = useTranslations();
return (
<DataTable
columns={columns}
data={data}
persistPageSize="userServer-table"
title={t('userServer')}
searchPlaceholder={t('userSearch')}
searchColumn="email"
/>
);
}

View File

@@ -0,0 +1,269 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { UsersDataTable } from "@app/components/AdminUsersDataTable";
import { Button } from "@app/components/ui/button";
import { ArrowRight, ArrowUpDown, MoreHorizontal } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { toast } from "@app/hooks/useToast";
import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useTranslations } from "next-intl";
import {
DropdownMenu,
DropdownMenuItem,
DropdownMenuContent,
DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu";
export type GlobalUserRow = {
id: string;
name: string | null;
username: string;
email: string | null;
type: string;
idpId: number | null;
idpName: string;
dateCreated: string;
twoFactorEnabled: boolean | null;
twoFactorSetupRequested: boolean | null;
};
type Props = {
users: GlobalUserRow[];
};
export default function UsersTable({ users }: Props) {
const router = useRouter();
const t = useTranslations();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selected, setSelected] = useState<GlobalUserRow | null>(null);
const [rows, setRows] = useState<GlobalUserRow[]>(users);
const api = createApiClient(useEnvContext());
const deleteUser = (id: string) => {
api.delete(`/user/${id}`)
.catch((e) => {
console.error(t("userErrorDelete"), e);
toast({
variant: "destructive",
title: t("userErrorDelete"),
description: formatAxiosError(e, t("userErrorDelete"))
});
})
.then(() => {
router.refresh();
setIsDeleteModalOpen(false);
const newRows = rows.filter((row) => row.id !== id);
setRows(newRows);
});
};
const columns: ColumnDef<GlobalUserRow>[] = [
{
accessorKey: "id",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
ID
</Button>
);
}
},
{
accessorKey: "username",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("username")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "email",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("email")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "name",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("name")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "idpName",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("identityProvider")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "twoFactorEnabled",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("twoFactor")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const userRow = row.original;
return (
<div className="flex flex-row items-center gap-2">
<span>
{userRow.twoFactorEnabled ||
userRow.twoFactorSetupRequested ? (
<span className="text-green-500">
{t("enabled")}
</span>
) : (
<span>{t("disabled")}</span>
)}
</span>
</div>
);
}
},
{
id: "actions",
cell: ({ row }) => {
const r = row.original;
return (
<>
<div className="flex items-center justify-end gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-8 w-8 p-0"
>
<span className="sr-only">
Open menu
</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
setSelected(r);
setIsDeleteModalOpen(true);
}}
>
{t("delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant={"secondary"}
size="sm"
onClick={() => {
router.push(`/admin/users/${r.id}`);
}}
>
{t("edit")}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</div>
</>
);
}
}
];
return (
<>
{selected && (
<ConfirmDeleteDialog
open={isDeleteModalOpen}
setOpen={(val) => {
setIsDeleteModalOpen(val);
setSelected(null);
}}
dialog={
<div className="space-y-4">
<p>
{t("userQuestionRemove", {
selectedUser:
selected?.email ||
selected?.name ||
selected?.username
})}
</p>
<p>
<b>{t("userMessageRemove")}</b>
</p>
<p>{t("userMessageConfirm")}</p>
</div>
}
buttonText={t("userDeleteConfirm")}
onConfirm={async () => deleteUser(selected!.id)}
string={
selected.email || selected.name || selected.username
}
title={t("userDeleteServer")}
/>
)}
<UsersDataTable columns={columns} data={rows} />
</>
);
}

View File

@@ -0,0 +1,58 @@
"use client";
import {
ColumnDef,
flexRender,
getCoreRowModel,
useReactTable,
getPaginationRowModel,
SortingState,
getSortedRowModel,
ColumnFiltersState,
getFilteredRowModel
} from "@tanstack/react-table";
import {
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableHeader,
TableRow
} from "@/components/ui/table";
import { Button } from "@app/components/ui/button";
import { useState } from "react";
import { Input } from "@app/components/ui/input";
import { DataTablePagination } from "@app/components/DataTablePagination";
import { Plus, Search } from "lucide-react";
import { DataTable } from "@app/components/ui/data-table";
import { useTranslations } from "next-intl";
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
addApiKey?: () => void;
}
export function ApiKeysDataTable<TData, TValue>({
addApiKey,
columns,
data
}: DataTableProps<TData, TValue>) {
const t = useTranslations();
return (
<DataTable
columns={columns}
data={data}
persistPageSize="apiKeys-table"
title={t('apiKeys')}
searchPlaceholder={t('searchApiKeys')}
searchColumn="name"
onAdd={addApiKey}
addButtonText={t('apiKeysAdd')}
/>
);
}

View File

@@ -0,0 +1,192 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu";
import { Button } from "@app/components/ui/button";
import { ArrowRight, ArrowUpDown, MoreHorizontal } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { toast } from "@app/hooks/useToast";
import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import moment from "moment";
import { ApiKeysDataTable } from "@app/components/ApiKeysDataTable";
import { useTranslations } from "next-intl";
export type ApiKeyRow = {
id: string;
key: string;
name: string;
createdAt: string;
};
type ApiKeyTableProps = {
apiKeys: ApiKeyRow[];
};
export default function ApiKeysTable({ apiKeys }: ApiKeyTableProps) {
const router = useRouter();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selected, setSelected] = useState<ApiKeyRow | null>(null);
const [rows, setRows] = useState<ApiKeyRow[]>(apiKeys);
const api = createApiClient(useEnvContext());
const t = useTranslations();
const deleteSite = (apiKeyId: string) => {
api.delete(`/api-key/${apiKeyId}`)
.catch((e) => {
console.error(t("apiKeysErrorDelete"), e);
toast({
variant: "destructive",
title: t("apiKeysErrorDelete"),
description: formatAxiosError(
e,
t("apiKeysErrorDeleteMessage")
)
});
})
.then(() => {
router.refresh();
setIsDeleteModalOpen(false);
const newRows = rows.filter((row) => row.id !== apiKeyId);
setRows(newRows);
});
};
const columns: ColumnDef<ApiKeyRow>[] = [
{
accessorKey: "name",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("name")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "key",
header: t("key"),
cell: ({ row }) => {
const r = row.original;
return <span className="font-mono">{r.key}</span>;
}
},
{
accessorKey: "createdAt",
header: t("createdAt"),
cell: ({ row }) => {
const r = row.original;
return <span>{moment(r.createdAt).format("lll")} </span>;
}
},
{
id: "actions",
cell: ({ row }) => {
const r = row.original;
return (
<div className="flex items-center justify-end gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">
{t("openMenu")}
</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
setSelected(r);
}}
>
<span>{t("viewSettings")}</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setSelected(r);
setIsDeleteModalOpen(true);
}}
>
<span className="text-red-500">
{t("delete")}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<div className="flex items-center justify-end">
<Link href={`/admin/api-keys/${r.id}`}>
<Button variant={"secondary"} className="ml-2" size="sm">
{t("edit")}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</Link>
</div>
</div>
);
}
}
];
return (
<>
{selected && (
<ConfirmDeleteDialog
open={isDeleteModalOpen}
setOpen={(val) => {
setIsDeleteModalOpen(val);
setSelected(null);
}}
dialog={
<div className="space-y-4">
<p>
{t("apiKeysQuestionRemove", {
selectedApiKey:
selected?.name || selected?.id
})}
</p>
<p>
<b>{t("apiKeysMessageRemove")}</b>
</p>
<p>{t("apiKeysMessageConfirm")}</p>
</div>
}
buttonText={t("apiKeysDeleteConfirm")}
onConfirm={async () => deleteSite(selected!.id)}
string={selected.name}
title={t("apiKeysDelete")}
/>
)}
<ApiKeysDataTable
columns={columns}
data={rows}
addApiKey={() => {
router.push(`/admin/api-keys/create`);
}}
/>
</>
);
}

View File

@@ -0,0 +1,100 @@
"use client";
import { useEffect, useState } from "react";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { GenerateOidcUrlResponse } from "@server/routers/idp";
import { AxiosResponse } from "axios";
import { useRouter } from "next/navigation";
import {
Card,
CardHeader,
CardTitle,
CardContent,
CardDescription
} from "@app/components/ui/card";
import { Alert, AlertDescription } from "@app/components/ui/alert";
import { Loader2, CheckCircle2, AlertCircle } from "lucide-react";
import { useTranslations } from "next-intl";
type AutoLoginHandlerProps = {
resourceId: number;
skipToIdpId: number;
redirectUrl: string;
};
export default function AutoLoginHandler({
resourceId,
skipToIdpId,
redirectUrl
}: AutoLoginHandlerProps) {
const { env } = useEnvContext();
const api = createApiClient({ env });
const router = useRouter();
const t = useTranslations();
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
async function initiateAutoLogin() {
setLoading(true);
try {
const res = await api.post<
AxiosResponse<GenerateOidcUrlResponse>
>(`/auth/idp/${skipToIdpId}/oidc/generate-url`, {
redirectUrl
});
if (res.data.data.redirectUrl) {
// Redirect to the IDP for authentication
window.location.href = res.data.data.redirectUrl;
} else {
setError(t("autoLoginErrorNoRedirectUrl"));
}
} catch (e) {
console.error("Failed to generate OIDC URL:", e);
setError(formatAxiosError(e, t("autoLoginErrorGeneratingUrl")));
} finally {
setLoading(false);
}
}
initiateAutoLogin();
}, []);
return (
<div className="flex items-center justify-center min-h-screen">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>{t("autoLoginTitle")}</CardTitle>
<CardDescription>{t("autoLoginDescription")}</CardDescription>
</CardHeader>
<CardContent className="flex flex-col items-center space-y-4">
{loading && (
<div className="flex items-center space-x-2">
<Loader2 className="h-5 w-5 animate-spin" />
<span>{t("autoLoginProcessing")}</span>
</div>
)}
{!loading && !error && (
<div className="flex items-center space-x-2 text-green-600">
<CheckCircle2 className="h-5 w-5" />
<span>{t("autoLoginRedirecting")}</span>
</div>
)}
{error && (
<Alert variant="destructive" className="w-full">
<AlertCircle className="h-5 w-5" />
<AlertDescription className="flex flex-col space-y-2">
<span>{t("autoLoginError")}</span>
<span className="text-xs">{error}</span>
</AlertDescription>
</Alert>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,54 @@
"use client";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { InfoIcon } from "lucide-react";
import { useClientContext } from "@app/hooks/useClientContext";
import {
InfoSection,
InfoSectionContent,
InfoSections,
InfoSectionTitle
} from "@app/components/InfoSection";
import { useTranslations } from "next-intl";
type ClientInfoCardProps = {};
export default function SiteInfoCard({}: ClientInfoCardProps) {
const { client, updateClient } = useClientContext();
const t = useTranslations();
return (
<Alert>
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">{t("clientInformation")}</AlertTitle>
<AlertDescription className="mt-4">
<InfoSections cols={2}>
<>
<InfoSection>
<InfoSectionTitle>{t("status")}</InfoSectionTitle>
<InfoSectionContent>
{client.online ? (
<div className="text-green-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span>{t("online")}</span>
</div>
) : (
<div className="text-neutral-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-gray-500 rounded-full"></div>
<span>{t("offline")}</span>
</div>
)}
</InfoSectionContent>
</InfoSection>
</>
<InfoSection>
<InfoSectionTitle>{t("address")}</InfoSectionTitle>
<InfoSectionContent>
{client.subnet.split("/")[0]}
</InfoSectionContent>
</InfoSection>
</InfoSections>
</AlertDescription>
</Alert>
);
}

View File

@@ -0,0 +1,31 @@
"use client";
import {
ColumnDef,
} from "@tanstack/react-table";
import { DataTable } from "@app/components/ui/data-table";
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
addClient?: () => void;
}
export function ClientsDataTable<TData, TValue>({
columns,
data,
addClient
}: DataTableProps<TData, TValue>) {
return (
<DataTable
columns={columns}
data={data}
persistPageSize="clients-table"
title="Clients"
searchPlaceholder="Search clients..."
searchColumn="name"
onAdd={addClient}
addButtonText="Add Client"
/>
);
}

View File

@@ -0,0 +1,298 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { ClientsDataTable } from "@app/components/ClientsDataTable";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu";
import { Button } from "@app/components/ui/button";
import {
ArrowRight,
ArrowUpDown,
ArrowUpRight,
Check,
MoreHorizontal,
X
} from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { toast } from "@app/hooks/useToast";
import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
export type ClientRow = {
id: number;
name: string;
subnet: string;
// siteIds: string;
mbIn: string;
mbOut: string;
orgId: string;
online: boolean;
};
type ClientTableProps = {
clients: ClientRow[];
orgId: string;
};
export default function ClientsTable({ clients, orgId }: ClientTableProps) {
const router = useRouter();
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selectedClient, setSelectedClient] = useState<ClientRow | null>(
null
);
const [rows, setRows] = useState<ClientRow[]>(clients);
const api = createApiClient(useEnvContext());
const deleteClient = (clientId: number) => {
api.delete(`/client/${clientId}`)
.catch((e) => {
console.error("Error deleting client", e);
toast({
variant: "destructive",
title: "Error deleting client",
description: formatAxiosError(e, "Error deleting client")
});
})
.then(() => {
router.refresh();
setIsDeleteModalOpen(false);
const newRows = rows.filter((row) => row.id !== clientId);
setRows(newRows);
});
};
const columns: ColumnDef<ClientRow>[] = [
{
accessorKey: "name",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Name
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
// {
// accessorKey: "siteName",
// header: ({ column }) => {
// return (
// <Button
// variant="ghost"
// onClick={() =>
// column.toggleSorting(column.getIsSorted() === "asc")
// }
// >
// Site
// <ArrowUpDown className="ml-2 h-4 w-4" />
// </Button>
// );
// },
// cell: ({ row }) => {
// const r = row.original;
// return (
// <Link href={`/${r.orgId}/settings/sites/${r.siteId}`}>
// <Button variant="outline">
// {r.siteName}
// <ArrowUpRight className="ml-2 h-4 w-4" />
// </Button>
// </Link>
// );
// }
// },
{
accessorKey: "online",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Connectivity
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const originalRow = row.original;
if (originalRow.online) {
return (
<span className="text-green-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span>Connected</span>
</span>
);
} else {
return (
<span className="text-neutral-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-gray-500 rounded-full"></div>
<span>Disconnected</span>
</span>
);
}
}
},
{
accessorKey: "mbIn",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Data In
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "mbOut",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Data Out
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "subnet",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Address
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
id: "actions",
cell: ({ row }) => {
const clientRow = row.original;
return (
<div className="flex items-center justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{/* <Link */}
{/* className="block w-full" */}
{/* href={`/${clientRow.orgId}/settings/sites/${clientRow.nice}`} */}
{/* > */}
{/* <DropdownMenuItem> */}
{/* View settings */}
{/* </DropdownMenuItem> */}
{/* </Link> */}
<DropdownMenuItem
onClick={() => {
setSelectedClient(clientRow);
setIsDeleteModalOpen(true);
}}
>
<span className="text-red-500">Delete</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Link
href={`/${clientRow.orgId}/settings/clients/${clientRow.id}`}
>
<Button variant={"secondary"} className="ml-2">
Edit
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</Link>
</div>
);
}
}
];
return (
<>
{selectedClient && (
<ConfirmDeleteDialog
open={isDeleteModalOpen}
setOpen={(val) => {
setIsDeleteModalOpen(val);
setSelectedClient(null);
}}
dialog={
<div className="space-y-4">
<p>
Are you sure you want to remove the client{" "}
<b>
{selectedClient?.name || selectedClient?.id}
</b>{" "}
from the site and organization?
</p>
<p>
<b>
Once removed, the client will no longer be
able to connect to the site.{" "}
</b>
</p>
<p>
To confirm, please type the name of the client
below.
</p>
</div>
}
buttonText="Confirm Delete Client"
onConfirm={async () => deleteClient(selectedClient!.id)}
string={selectedClient.name}
title="Delete Client"
/>
)}
<ClientsDataTable
columns={columns}
data={rows}
addClient={() => {
router.push(`/${orgId}/settings/clients/create`);
}}
/>
</>
);
}

View File

@@ -0,0 +1,607 @@
"use client";
import { Button } from "@app/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
FormDescription
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
import { useToast } from "@app/hooks/useToast";
import { zodResolver } from "@hookform/resolvers/zod";
import { useState, useMemo } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import {
Credenza,
CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "@app/components/Credenza";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useTranslations } from "next-intl";
import { formatAxiosError } from "@app/lib/api";
import { CreateDomainResponse } from "@server/routers/domain/createOrgDomain";
import { StrategySelect } from "@app/components/StrategySelect";
import { AxiosResponse } from "axios";
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { InfoIcon, AlertTriangle, Globe } from "lucide-react";
import CopyToClipboard from "@app/components/CopyToClipboard";
import {
InfoSection,
InfoSectionContent,
InfoSections,
InfoSectionTitle
} from "@app/components/InfoSection";
import { useOrgContext } from "@app/hooks/useOrgContext";
import { build } from "@server/build";
import { toASCII, toUnicode } from 'punycode';
// Helper functions for Unicode domain handling
function toPunycode(domain: string): string {
try {
const parts = toASCII(domain);
return parts;
} catch (error) {
return domain.toLowerCase();
}
}
function fromPunycode(domain: string): string {
try {
const parts = toUnicode(domain);
return parts;
} catch (error) {
return domain;
}
}
function isValidDomainFormat(domain: string): boolean {
const unicodeRegex = /^(?!:\/\/)([^\s.]+\.)*[^\s.]+$/;
if (!unicodeRegex.test(domain)) {
return false;
}
const parts = domain.split('.');
for (const part of parts) {
if (part.length === 0 || part.startsWith('-') || part.endsWith('-')) {
return false;
}
if (part.length > 63) {
return false;
}
}
if (domain.length > 253) {
return false;
}
return true;
}
const formSchema = z.object({
baseDomain: z
.string()
.min(1, "Domain is required")
.refine((val) => isValidDomainFormat(val), "Invalid domain format")
.transform((val) => toPunycode(val)),
type: z.enum(["ns", "cname", "wildcard"])
});
type FormValues = z.infer<typeof formSchema>;
type CreateDomainFormProps = {
open: boolean;
setOpen: (open: boolean) => void;
onCreated?: (domain: CreateDomainResponse) => void;
};
export default function CreateDomainForm({
open,
setOpen,
onCreated
}: CreateDomainFormProps) {
const [loading, setLoading] = useState(false);
const [createdDomain, setCreatedDomain] =
useState<CreateDomainResponse | null>(null);
const api = createApiClient(useEnvContext());
const t = useTranslations();
const { toast } = useToast();
const { org } = useOrgContext();
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
baseDomain: "",
type: build == "oss" ? "wildcard" : "ns"
}
});
function reset() {
form.reset();
setLoading(false);
setCreatedDomain(null);
}
async function onSubmit(values: FormValues) {
setLoading(true);
try {
const response = await api.put<AxiosResponse<CreateDomainResponse>>(
`/org/${org.org.orgId}/domain`,
values
);
const domainData = response.data.data;
setCreatedDomain(domainData);
toast({
title: t("success"),
description: t("domainCreatedDescription")
});
onCreated?.(domainData);
} catch (e) {
toast({
title: t("error"),
description: formatAxiosError(e),
variant: "destructive"
});
} finally {
setLoading(false);
}
}
const baseDomain = form.watch("baseDomain");
const domainInputValue = form.watch("baseDomain") || "";
const punycodePreview = useMemo(() => {
if (!domainInputValue) return "";
const punycode = toPunycode(domainInputValue);
return punycode !== domainInputValue.toLowerCase() ? punycode : "";
}, [domainInputValue]);
let domainOptions: any = [];
if (build == "enterprise" || build == "saas") {
domainOptions = [
{
id: "ns",
title: t("selectDomainTypeNsName"),
description: t("selectDomainTypeNsDescription")
},
{
id: "cname",
title: t("selectDomainTypeCnameName"),
description: t("selectDomainTypeCnameDescription")
}
];
} else if (build == "oss") {
domainOptions = [
{
id: "wildcard",
title: t("selectDomainTypeWildcardName"),
description: t("selectDomainTypeWildcardDescription")
}
];
}
return (
<Credenza
open={open}
onOpenChange={(val) => {
setOpen(val);
reset();
}}
>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>{t("domainAdd")}</CredenzaTitle>
<CredenzaDescription>
{t("domainAddDescription")}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
{!createdDomain ? (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
id="create-domain-form"
>
<FormField
control={form.control}
name="type"
render={({ field }) => (
<FormItem>
<StrategySelect
options={domainOptions}
defaultValue={field.value}
onChange={field.onChange}
cols={1}
/>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="baseDomain"
render={({ field }) => (
<FormItem>
<FormLabel>{t("domain")}</FormLabel>
<FormControl>
<Input
placeholder="example.com, café.com, 日本.com"
{...field}
/>
</FormControl>
{punycodePreview && (
<FormDescription className="flex items-center gap-2 text-xs">
<Alert>
<Globe className="h-4 w-4" />
<AlertTitle>{t("internationaldomaindetected")}</AlertTitle>
<AlertDescription>
<div className="mt-2 space-y-1">
<p>{t("willbestoredas")} <code className="font-mono px-1 py-0.5 rounded">{punycodePreview}</code></p>
</div>
</AlertDescription>
</Alert>
</FormDescription>
)}
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
) : (
<div className="space-y-6">
<Alert variant="default">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
{t("createDomainAddDnsRecords")}
</AlertTitle>
<AlertDescription>
{t("createDomainAddDnsRecordsDescription")}
</AlertDescription>
</Alert>
<div className="space-y-4">
{createdDomain.nsRecords &&
createdDomain.nsRecords.length > 0 && (
<div>
<h3 className="font-medium mb-3">
{t("createDomainNsRecords")}
</h3>
<InfoSections cols={1}>
<InfoSection>
<InfoSectionTitle>
{t("createDomainRecord")}
</InfoSectionTitle>
<InfoSectionContent>
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm font-medium">
{t(
"createDomainType"
)}
</span>
<span className="text-sm font-mono">
NS
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm font-medium">
{t(
"createDomainName"
)}
</span>
<div className="text-right">
<span className="text-sm font-mono block">
{fromPunycode(baseDomain)}
</span>
{fromPunycode(baseDomain) !== baseDomain && (
<span className="text-xs text-muted-foreground font-mono">
({baseDomain})
</span>
)}
</div>
</div>
<span className="text-sm font-medium">
{t(
"createDomainValue"
)}
</span>
{createdDomain.nsRecords.map(
(
nsRecord,
index
) => (
<div
className="flex justify-between items-center"
key={index}
>
<CopyToClipboard
text={
nsRecord
}
/>
</div>
)
)}
</div>
</InfoSectionContent>
</InfoSection>
</InfoSections>
</div>
)}
{createdDomain.cnameRecords &&
createdDomain.cnameRecords.length > 0 && (
<div>
<h3 className="font-medium mb-3">
{t("createDomainCnameRecords")}
</h3>
<InfoSections cols={1}>
{createdDomain.cnameRecords.map(
(cnameRecord, index) => (
<InfoSection
key={index}
>
<InfoSectionTitle>
{t(
"createDomainRecordNumber",
{
number:
index +
1
}
)}
</InfoSectionTitle>
<InfoSectionContent>
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm font-medium">
{t(
"createDomainType"
)}
</span>
<span className="text-sm font-mono">
CNAME
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm font-medium">
{t(
"createDomainName"
)}
</span>
<div className="text-right">
<span className="text-sm font-mono block">
{fromPunycode(cnameRecord.baseDomain)}
</span>
{fromPunycode(cnameRecord.baseDomain) !== cnameRecord.baseDomain && (
<span className="text-xs text-muted-foreground font-mono">
({cnameRecord.baseDomain})
</span>
)}
</div>
</div>
<div className="flex justify-between items-center">
<span className="text-sm font-medium">
{t(
"createDomainValue"
)}
</span>
<CopyToClipboard
text={
cnameRecord.value
}
/>
</div>
</div>
</InfoSectionContent>
</InfoSection>
)
)}
</InfoSections>
</div>
)}
{createdDomain.aRecords &&
createdDomain.aRecords.length > 0 && (
<div>
<h3 className="font-medium mb-3">
{t("createDomainARecords")}
</h3>
<InfoSections cols={1}>
{createdDomain.aRecords.map(
(aRecord, index) => (
<InfoSection
key={index}
>
<InfoSectionTitle>
{t(
"createDomainRecordNumber",
{
number:
index +
1
}
)}
</InfoSectionTitle>
<InfoSectionContent>
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm font-medium">
{t(
"createDomainType"
)}
</span>
<span className="text-sm font-mono">
A
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm font-medium">
{t(
"createDomainName"
)}
</span>
<div className="text-right">
<span className="text-sm font-mono block">
{fromPunycode(aRecord.baseDomain)}
</span>
{fromPunycode(aRecord.baseDomain) !== aRecord.baseDomain && (
<span className="text-xs text-muted-foreground font-mono">
({aRecord.baseDomain})
</span>
)}
</div>
</div>
<div className="flex justify-between items-center">
<span className="text-sm font-medium">
{t(
"createDomainValue"
)}
</span>
<span className="text-sm font-mono">
{
aRecord.value
}
</span>
</div>
</div>
</InfoSectionContent>
</InfoSection>
)
)}
</InfoSections>
</div>
)}
{createdDomain.txtRecords &&
createdDomain.txtRecords.length > 0 && (
<div>
<h3 className="font-medium mb-3">
{t("createDomainTxtRecords")}
</h3>
<InfoSections cols={1}>
{createdDomain.txtRecords.map(
(txtRecord, index) => (
<InfoSection
key={index}
>
<InfoSectionTitle>
{t(
"createDomainRecordNumber",
{
number:
index +
1
}
)}
</InfoSectionTitle>
<InfoSectionContent>
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm font-medium">
{t(
"createDomainType"
)}
</span>
<span className="text-sm font-mono">
TXT
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm font-medium">
{t(
"createDomainName"
)}
</span>
<div className="text-right">
<span className="text-sm font-mono block">
{fromPunycode(txtRecord.baseDomain)}
</span>
{fromPunycode(txtRecord.baseDomain) !== txtRecord.baseDomain && (
<span className="text-xs text-muted-foreground font-mono">
({txtRecord.baseDomain})
</span>
)}
</div>
</div>
<div className="flex justify-between items-center">
<span className="text-sm font-medium">
{t(
"createDomainValue"
)}
</span>
<CopyToClipboard
text={
txtRecord.value
}
/>
</div>
</div>
</InfoSectionContent>
</InfoSection>
)
)}
</InfoSections>
</div>
)}
</div>
{build == "saas" ||
(build == "enterprise" && (
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertTitle className="font-semibold">
{t("createDomainSaveTheseRecords")}
</AlertTitle>
<AlertDescription>
{t(
"createDomainSaveTheseRecordsDescription"
)}
</AlertDescription>
</Alert>
))}
<Alert variant="info">
<AlertTriangle className="h-4 w-4" />
<AlertTitle className="font-semibold">
{t("createDomainDnsPropagation")}
</AlertTitle>
<AlertDescription>
{t("createDomainDnsPropagationDescription")}
</AlertDescription>
</Alert>
</div>
)}
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">{t("close")}</Button>
</CredenzaClose>
{!createdDomain && (
<Button
type="submit"
form="create-domain-form"
loading={loading}
disabled={loading}
>
{t("domainCreate")}
</Button>
)}
</CredenzaFooter>
</CredenzaContent>
</Credenza>
);
}

View File

@@ -0,0 +1,178 @@
"use client";
import { Button } from "@app/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
import { toast } from "@app/hooks/useToast";
import { zodResolver } from "@hookform/resolvers/zod";
import { AxiosResponse } from "axios";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import {
Credenza,
CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "@app/components/Credenza";
import { useOrgContext } from "@app/hooks/useOrgContext";
import { CreateRoleBody, CreateRoleResponse } from "@server/routers/role";
import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useTranslations } from "next-intl";
type CreateRoleFormProps = {
open: boolean;
setOpen: (open: boolean) => void;
afterCreate?: (res: CreateRoleResponse) => Promise<void>;
};
export default function CreateRoleForm({
open,
setOpen,
afterCreate
}: CreateRoleFormProps) {
const { org } = useOrgContext();
const t = useTranslations();
const formSchema = z.object({
name: z.string({ message: t('nameRequired') }).max(32),
description: z.string().max(255).optional()
});
const [loading, setLoading] = useState(false);
const api = createApiClient(useEnvContext());
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
name: "",
description: ""
}
});
async function onSubmit(values: z.infer<typeof formSchema>) {
setLoading(true);
const res = await api
.put<AxiosResponse<CreateRoleResponse>>(
`/org/${org?.org.orgId}/role`,
{
name: values.name,
description: values.description
} as CreateRoleBody
)
.catch((e) => {
toast({
variant: "destructive",
title: t('accessRoleErrorCreate'),
description: formatAxiosError(
e,
t('accessRoleErrorCreateDescription')
)
});
});
if (res && res.status === 201) {
toast({
variant: "default",
title: t('accessRoleCreated'),
description: t('accessRoleCreatedDescription')
});
if (open) {
setOpen(false);
}
if (afterCreate) {
afterCreate(res.data.data);
}
}
setLoading(false);
}
return (
<>
<Credenza
open={open}
onOpenChange={(val) => {
setOpen(val);
setLoading(false);
form.reset();
}}
>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>{t('accessRoleCreate')}</CredenzaTitle>
<CredenzaDescription>
{t('accessRoleCreateDescription')}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
id="create-role-form"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>{t('accessRoleName')}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>{t('description')}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">{t('close')}</Button>
</CredenzaClose>
<Button
type="submit"
form="create-role-form"
loading={loading}
disabled={loading}
>
{t('accessRoleCreateSubmit')}
</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
</>
);
}

View File

@@ -0,0 +1,549 @@
"use client";
import { Button } from "@app/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@app/components/ui/select";
import { toast } from "@app/hooks/useToast";
import { zodResolver } from "@hookform/resolvers/zod";
import { AxiosResponse } from "axios";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import CopyTextBox from "@app/components/CopyTextBox";
import {
Credenza,
CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "@app/components/Credenza";
import { useOrgContext } from "@app/hooks/useOrgContext";
import { formatAxiosError } from "@app/lib/api";
import { cn } from "@app/lib/cn";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { ListResourcesResponse } from "@server/routers/resource";
import {
Popover,
PopoverContent,
PopoverTrigger
} from "@app/components/ui/popover";
import { CaretSortIcon } from "@radix-ui/react-icons";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList
} from "@app/components/ui/command";
import { CheckIcon, ChevronsUpDown } from "lucide-react";
import { Checkbox } from "@app/components/ui/checkbox";
import { GenerateAccessTokenResponse } from "@server/routers/accessToken";
import { constructShareLink } from "@app/lib/shareLinks";
import { ShareLinkRow } from "@app/components/ShareLinksTable";
import { QRCodeCanvas, QRCodeSVG } from "qrcode.react";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger
} from "@app/components/ui/collapsible";
import AccessTokenSection from "@app/components/AccessTokenUsage";
import { useTranslations } from "next-intl";
import { toUnicode } from 'punycode';
type FormProps = {
open: boolean;
setOpen: (open: boolean) => void;
onCreated?: (result: ShareLinkRow) => void;
};
export default function CreateShareLinkForm({
open,
setOpen,
onCreated
}: FormProps) {
const { org } = useOrgContext();
const { env } = useEnvContext();
const api = createApiClient({ env });
const [link, setLink] = useState<string | null>(null);
const [accessTokenId, setAccessTokenId] = useState<string | null>(null);
const [accessToken, setAccessToken] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [neverExpire, setNeverExpire] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const t = useTranslations();
const [resources, setResources] = useState<
{
resourceId: number;
name: string;
resourceUrl: string;
}[]
>([]);
const formSchema = z.object({
resourceId: z.number({ message: t('shareErrorSelectResource') }),
resourceName: z.string(),
resourceUrl: z.string(),
timeUnit: z.string(),
timeValue: z.coerce.number().int().positive().min(1),
title: z.string().optional()
});
const timeUnits = [
{ unit: "minutes", name: t('minutes') },
{ unit: "hours", name: t('hours') },
{ unit: "days", name: t('days') },
{ unit: "weeks", name: t('weeks') },
{ unit: "months", name: t('months') },
{ unit: "years", name: t('years') }
];
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
timeUnit: "days",
timeValue: 30,
title: ""
}
});
useEffect(() => {
if (!open) {
return;
}
async function fetchResources() {
const res = await api
.get<
AxiosResponse<ListResourcesResponse>
>(`/org/${org?.org.orgId}/resources`)
.catch((e) => {
console.error(e);
toast({
variant: "destructive",
title: t('shareErrorFetchResource'),
description: formatAxiosError(
e,
t('shareErrorFetchResourceDescription')
)
});
});
if (res?.status === 200) {
setResources(
res.data.data.resources
.filter((r) => {
return r.http;
})
.map((r) => ({
resourceId: r.resourceId,
name: r.name,
resourceUrl: `${r.ssl ? "https://" : "http://"}${toUnicode(r.fullDomain || "")}/`
}))
);
}
}
fetchResources();
}, [open]);
async function onSubmit(values: z.infer<typeof formSchema>) {
setLoading(true);
// convert time to seconds
let timeInSeconds = values.timeValue;
switch (values.timeUnit) {
case "minutes":
timeInSeconds *= 60;
break;
case "hours":
timeInSeconds *= 60 * 60;
break;
case "days":
timeInSeconds *= 60 * 60 * 24;
break;
case "weeks":
timeInSeconds *= 60 * 60 * 24 * 7;
break;
case "months":
timeInSeconds *= 60 * 60 * 24 * 30;
break;
case "years":
timeInSeconds *= 60 * 60 * 24 * 365;
break;
}
const res = await api
.post<AxiosResponse<GenerateAccessTokenResponse>>(
`/resource/${values.resourceId}/access-token`,
{
validForSeconds: neverExpire ? undefined : timeInSeconds,
title:
values.title ||
t('shareLink', {resource: (values.resourceName || "Resource" + values.resourceId)})
}
)
.catch((e) => {
console.error(e);
toast({
variant: "destructive",
title: t('shareErrorCreate'),
description: formatAxiosError(
e,
t('shareErrorCreateDescription')
)
});
});
if (res && res.data.data.accessTokenId) {
const token = res.data.data;
const link = constructShareLink(token.accessToken);
setLink(link);
setAccessToken(token.accessToken);
setAccessTokenId(token.accessTokenId);
const resource = resources.find(
(r) => r.resourceId === values.resourceId
);
onCreated?.({
accessTokenId: token.accessTokenId,
resourceId: token.resourceId,
resourceName: values.resourceName,
title: token.title,
createdAt: token.createdAt,
expiresAt: token.expiresAt
});
}
setLoading(false);
}
function getSelectedResourceName(id: number) {
const resource = resources.find((r) => r.resourceId === id);
return `${resource?.name}`;
}
return (
<>
<Credenza
open={open}
onOpenChange={(val) => {
setOpen(val);
setLink(null);
setLoading(false);
form.reset();
}}
>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>{t('shareCreate')}</CredenzaTitle>
<CredenzaDescription>
{t('shareCreateDescription')}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<div className="space-y-4">
{!link && (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
id="share-link-form"
>
<FormField
control={form.control}
name="resourceId"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>
{t('resource')}
</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
role="combobox"
className={cn(
"justify-between",
!field.value &&
"text-muted-foreground"
)}
>
{field.value
? getSelectedResourceName(
field.value
)
: t('resourceSelect')}
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="p-0">
<Command>
<CommandInput placeholder={t('resourceSearch')} />
<CommandList>
<CommandEmpty>
{t('resourcesNotFound')}
</CommandEmpty>
<CommandGroup>
{resources.map(
(
r
) => (
<CommandItem
value={`${r.name}:${r.resourceId}`}
key={
r.resourceId
}
onSelect={() => {
form.setValue(
"resourceId",
r.resourceId
);
form.setValue(
"resourceName",
r.name
);
form.setValue(
"resourceUrl",
r.resourceUrl
);
}}
>
<CheckIcon
className={cn(
"mr-2 h-4 w-4",
r.resourceId ===
field.value
? "opacity-100"
: "opacity-0"
)}
/>
{`${r.name}`}
</CommandItem>
)
)}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('shareTitleOptional')}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="space-y-4">
<div className="space-y-2">
<FormLabel>{t('expireIn')}</FormLabel>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="timeUnit"
render={({ field }) => (
<FormItem>
<Select
onValueChange={
field.onChange
}
defaultValue={field.value.toString()}
>
<FormControl>
<SelectTrigger className="w-full">
<SelectValue placeholder={t('selectDuration')} />
</SelectTrigger>
</FormControl>
<SelectContent>
{timeUnits.map(
(
option
) => (
<SelectItem
key={
option.unit
}
value={
option.unit
}
>
{
option.name
}
</SelectItem>
)
)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="timeValue"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
type="number"
min={1}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="terms"
checked={neverExpire}
onCheckedChange={(val) =>
setNeverExpire(
val as boolean
)
}
/>
<label
htmlFor="terms"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{t('neverExpire')}
</label>
</div>
<p className="text-sm text-muted-foreground">
{t('shareExpireDescription')}
</p>
</div>
</form>
</Form>
)}
{link && (
<div className="max-w-md space-y-4">
<p>
{t('shareSeeOnce')}
</p>
<p>
{t('shareAccessHint')}
</p>
<div className="h-[250px] w-full mx-auto flex items-center justify-center">
<QRCodeCanvas value={link} size={200} />
</div>
<Collapsible
open={isOpen}
onOpenChange={setIsOpen}
className="space-y-2"
>
<div className="mx-auto">
<CopyTextBox
text={link}
wrapText={false}
/>
</div>
<div className="flex items-center justify-between space-x-4">
<CollapsibleTrigger asChild>
<Button
variant="text"
size="sm"
className="p-0 flex items-center justify-between w-full"
>
<h4 className="text-sm font-semibold">
{t('shareTokenUsage')}
</h4>
<div>
<ChevronsUpDown className="h-4 w-4" />
<span className="sr-only">
{t('toggle')}
</span>
</div>
</Button>
</CollapsibleTrigger>
</div>
<CollapsibleContent className="space-y-2">
{accessTokenId && accessToken && (
<div className="space-y-2">
<div className="mx-auto">
<AccessTokenSection
tokenId={
accessTokenId
}
token={accessToken}
resourceUrl={form.getValues(
"resourceUrl"
)}
/>
</div>
</div>
)}
</CollapsibleContent>
</Collapsible>
</div>
)}
</div>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">{t('close')}</Button>
</CredenzaClose>
<Button
type="button"
onClick={form.handleSubmit(onSubmit)}
loading={loading}
disabled={link !== null || loading}
>
{t('createLink')}
</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
</>
);
}

View File

@@ -0,0 +1,103 @@
"use client";
import * as React from "react";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@/components/ui/select";
import { toUnicode } from "punycode";
interface DomainOption {
baseDomain: string;
domainId: string;
}
interface CustomDomainInputProps {
domainOptions: DomainOption[];
selectedDomainId?: string;
placeholder?: string;
value: string;
onChange?: (value: string, selectedDomainId: string) => void;
}
export default function CustomDomainInput({
domainOptions,
selectedDomainId,
placeholder = "Subdomain",
value: defaultValue,
onChange
}: CustomDomainInputProps) {
const [value, setValue] = React.useState(defaultValue);
const [selectedDomain, setSelectedDomain] = React.useState<DomainOption>();
React.useEffect(() => {
if (domainOptions.length) {
if (selectedDomainId) {
const selectedDomainOption = domainOptions.find(
(option) => option.domainId === selectedDomainId
);
setSelectedDomain(selectedDomainOption || domainOptions[0]);
} else {
setSelectedDomain(domainOptions[0]);
}
}
}, [domainOptions]);
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
if (!selectedDomain) {
return;
}
const newValue = event.target.value;
setValue(newValue);
if (onChange) {
onChange(newValue, selectedDomain.domainId);
}
};
const handleDomainChange = (domainId: string) => {
const newSelectedDomain =
domainOptions.find((option) => option.domainId === domainId) ||
domainOptions[0];
setSelectedDomain(newSelectedDomain);
if (onChange) {
onChange(value, newSelectedDomain.domainId);
}
};
return (
<div className="w-full">
<div className="flex">
<Input
type="text"
placeholder={placeholder}
value={value}
onChange={handleInputChange}
className="w-1/2 mr-1 text-right"
/>
<Select
onValueChange={handleDomainChange}
value={selectedDomain?.domainId}
defaultValue={selectedDomain?.domainId}
>
<SelectTrigger className="w-1/2 pr-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{domainOptions.map((option) => (
<SelectItem
key={option.domainId}
value={option.domainId}
>
.{toUnicode(option.baseDomain)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
);
}

View File

@@ -0,0 +1,63 @@
"use client";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from "@/components/ui/card";
import { createApiClient } from "@app/lib/api";
import LoginForm, { LoginFormIDP } from "@app/components/LoginForm";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import Image from "next/image";
import { cleanRedirect } from "@app/lib/cleanRedirect";
import BrandingLogo from "@app/components/BrandingLogo";
import { useTranslations } from "next-intl";
type DashboardLoginFormProps = {
redirect?: string;
idps?: LoginFormIDP[];
};
export default function DashboardLoginForm({
redirect,
idps
}: DashboardLoginFormProps) {
const router = useRouter();
const { env } = useEnvContext();
const t = useTranslations();
function getSubtitle() {
return t("loginStart");
}
return (
<Card className="shadow-md w-full max-w-md">
<CardHeader className="border-b">
<div className="flex flex-row items-center justify-center">
<BrandingLogo height={58} width={175} />
</div>
<div className="text-center space-y-1 pt-3">
<p className="text-muted-foreground">{getSubtitle()}</p>
</div>
</CardHeader>
<CardContent className="pt-6">
<LoginForm
redirect={redirect}
idps={idps}
onLogin={() => {
if (redirect) {
const safe = cleanRedirect(redirect);
router.push(safe);
} else {
router.push("/");
}
}}
/>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,231 @@
"use client";
import { Button } from "@app/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import { toast } from "@app/hooks/useToast";
import { zodResolver } from "@hookform/resolvers/zod";
import { AxiosResponse } from "axios";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import {
Credenza,
CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "@app/components/Credenza";
import { useOrgContext } from "@app/hooks/useOrgContext";
import { ListRolesResponse } from "@server/routers/role";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@app/components/ui/select";
import { RoleRow } from "@app/components/RolesTable";
import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useTranslations } from "next-intl";
type CreateRoleFormProps = {
open: boolean;
roleToDelete: RoleRow;
setOpen: (open: boolean) => void;
afterDelete?: () => void;
};
export default function DeleteRoleForm({
open,
roleToDelete,
setOpen,
afterDelete
}: CreateRoleFormProps) {
const { org } = useOrgContext();
const t = useTranslations();
const [loading, setLoading] = useState(false);
const [roles, setRoles] = useState<ListRolesResponse["roles"]>([]);
const api = createApiClient(useEnvContext());
const formSchema = z.object({
newRoleId: z.string({ message: t('accessRoleErrorNewRequired') })
});
useEffect(() => {
async function fetchRoles() {
const res = await api
.get<
AxiosResponse<ListRolesResponse>
>(`/org/${org?.org.orgId}/roles`)
.catch((e) => {
console.error(e);
toast({
variant: "destructive",
title: t('accessRoleErrorFetch'),
description: formatAxiosError(
e,
t('accessRoleErrorFetchDescription')
)
});
});
if (res?.status === 200) {
setRoles(
res.data.data.roles.filter(
(r) => r.roleId !== roleToDelete.roleId
)
);
}
}
fetchRoles();
}, []);
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
newRoleId: ""
}
});
async function onSubmit(values: z.infer<typeof formSchema>) {
setLoading(true);
const res = await api
.delete(`/role/${roleToDelete.roleId}`, {
data: {
roleId: values.newRoleId
}
})
.catch((e) => {
toast({
variant: "destructive",
title: t('accessRoleErrorRemove'),
description: formatAxiosError(
e,
t('accessRoleErrorRemoveDescription')
)
});
});
if (res && res.status === 200) {
toast({
variant: "default",
title: t('accessRoleRemoved'),
description: t('accessRoleRemovedDescription')
});
if (open) {
setOpen(false);
}
if (afterDelete) {
afterDelete();
}
}
setLoading(false);
}
return (
<>
<Credenza
open={open}
onOpenChange={(val) => {
setOpen(val);
setLoading(false);
form.reset();
}}
>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>{t('accessRoleRemove')}</CredenzaTitle>
<CredenzaDescription>
{t('accessRoleRemoveDescription')}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<div className="space-y-4">
<p>
{t('accessRoleQuestionRemove', {name: roleToDelete.name})}
</p>
<p>
{t('accessRoleRequiredRemove')}
</p>
</div>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
id="remove-role-form"
>
<FormField
control={form.control}
name="newRoleId"
render={({ field }) => (
<FormItem>
<FormLabel>{t('role')}</FormLabel>
<Select
onValueChange={
field.onChange
}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t('accessRoleSelect')} />
</SelectTrigger>
</FormControl>
<SelectContent>
{roles.map((role) => (
<SelectItem
key={
role.roleId
}
value={role.roleId.toString()}
>
{role.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">{t('close')}</Button>
</CredenzaClose>
<Button
variant="destructive"
type="submit"
form="remove-role-form"
loading={loading}
disabled={loading}
>
{t('accessRoleRemoveSubmit')}
</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
</>
);
}

View File

@@ -0,0 +1,38 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { DataTable } from "@app/components/ui/data-table";
import { useTranslations } from "next-intl";
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
onAdd?: () => void;
onRefresh?: () => void;
isRefreshing?: boolean;
}
export function DomainsDataTable<TData, TValue>({
columns,
data,
onAdd,
onRefresh,
isRefreshing
}: DataTableProps<TData, TValue>) {
const t = useTranslations();
return (
<DataTable
columns={columns}
data={data}
persistPageSize="domains-table"
title={t("domains")}
searchPlaceholder={t("domainsSearch")}
searchColumn="baseDomain"
addButtonText={t("domainAdd")}
onAdd={onAdd}
onRefresh={onRefresh}
isRefreshing={isRefreshing}
/>
);
}

View File

@@ -0,0 +1,278 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { DomainsDataTable } from "@app/components/DomainsDataTable";
import { Button } from "@app/components/ui/button";
import { ArrowUpDown } from "lucide-react";
import { useState } from "react";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { Badge } from "@app/components/ui/badge";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import CreateDomainForm from "@app/components/CreateDomainForm";
import { useToast } from "@app/hooks/useToast";
import { useOrgContext } from "@app/hooks/useOrgContext";
export type DomainRow = {
domainId: string;
baseDomain: string;
type: string;
verified: boolean;
failed: boolean;
tries: number;
configManaged: boolean;
};
type Props = {
domains: DomainRow[];
};
export default function DomainsTable({ domains }: Props) {
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [selectedDomain, setSelectedDomain] = useState<DomainRow | null>(
null
);
const [isRefreshing, setIsRefreshing] = useState(false);
const [restartingDomains, setRestartingDomains] = useState<Set<string>>(
new Set()
);
const api = createApiClient(useEnvContext());
const router = useRouter();
const t = useTranslations();
const { toast } = useToast();
const { org } = useOrgContext();
const refreshData = async () => {
setIsRefreshing(true);
try {
await new Promise((resolve) => setTimeout(resolve, 200));
router.refresh();
} catch (error) {
toast({
title: t("error"),
description: t("refreshError"),
variant: "destructive"
});
} finally {
setIsRefreshing(false);
}
};
const deleteDomain = async (domainId: string) => {
try {
await api.delete(`/org/${org.org.orgId}/domain/${domainId}`);
toast({
title: t("success"),
description: t("domainDeletedDescription")
});
setIsDeleteModalOpen(false);
refreshData();
} catch (e) {
toast({
title: t("error"),
description: formatAxiosError(e),
variant: "destructive"
});
}
};
const restartDomain = async (domainId: string) => {
setRestartingDomains((prev) => new Set(prev).add(domainId));
try {
await api.post(`/org/${org.org.orgId}/domain/${domainId}/restart`);
toast({
title: t("success"),
description: t("domainRestartedDescription", {
fallback: "Domain verification restarted successfully"
})
});
refreshData();
} catch (e) {
toast({
title: t("error"),
description: formatAxiosError(e),
variant: "destructive"
});
} finally {
setRestartingDomains((prev) => {
const newSet = new Set(prev);
newSet.delete(domainId);
return newSet;
});
}
};
const getTypeDisplay = (type: string) => {
switch (type) {
case "ns":
return t("selectDomainTypeNsName");
case "cname":
return t("selectDomainTypeCnameName");
case "wildcard":
return t("selectDomainTypeWildcardName");
default:
return type;
}
};
const columns: ColumnDef<DomainRow>[] = [
{
accessorKey: "baseDomain",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("domain")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "type",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("type")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const type = row.original.type;
return (
<Badge variant="secondary">{getTypeDisplay(type)}</Badge>
);
}
},
{
accessorKey: "verified",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("status")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const { verified, failed } = row.original;
if (verified) {
return <Badge variant="green">{t("verified")}</Badge>;
} else if (failed) {
return (
<Badge variant="destructive">
{t("failed", { fallback: "Failed" })}
</Badge>
);
} else {
return <Badge variant="yellow">{t("pending")}</Badge>;
}
}
},
{
id: "actions",
cell: ({ row }) => {
const domain = row.original;
const isRestarting = restartingDomains.has(domain.domainId);
return (
<div className="flex items-center justify-end gap-2">
{domain.failed && (
<Button
variant="outline"
size="sm"
onClick={() => restartDomain(domain.domainId)}
disabled={isRestarting}
>
{isRestarting
? t("restarting", {
fallback: "Restarting..."
})
: t("restart", { fallback: "Restart" })}
</Button>
)}
<Button
variant="secondary"
size="sm"
disabled={domain.configManaged}
onClick={() => {
setSelectedDomain(domain);
setIsDeleteModalOpen(true);
}}
>
{t("delete")}
</Button>
</div>
);
}
}
];
return (
<>
{selectedDomain && (
<ConfirmDeleteDialog
open={isDeleteModalOpen}
setOpen={(val) => {
setIsDeleteModalOpen(val);
setSelectedDomain(null);
}}
dialog={
<div className="space-y-4">
<p>
{t("domainQuestionRemove", {
domain: selectedDomain.baseDomain
})}
</p>
<p>
<b>{t("domainMessageRemove")}</b>
</p>
<p>{t("domainMessageConfirm")}</p>
</div>
}
buttonText={t("domainConfirmDelete")}
onConfirm={async () =>
deleteDomain(selectedDomain.domainId)
}
string={selectedDomain.baseDomain}
title={t("domainDelete")}
/>
)}
<CreateDomainForm
open={isCreateModalOpen}
setOpen={setIsCreateModalOpen}
onCreated={(domain) => {
refreshData();
}}
/>
<DomainsDataTable
columns={columns}
data={domains}
onAdd={() => setIsCreateModalOpen(true)}
onRefresh={refreshData}
isRefreshing={isRefreshing}
/>
</>
);
}

View File

@@ -0,0 +1,429 @@
"use client";
import {
SettingsContainer,
SettingsSection,
SettingsSectionBody,
SettingsSectionDescription,
SettingsSectionForm,
SettingsSectionGrid,
SettingsSectionHeader,
SettingsSectionTitle
} from "@app/components/Settings";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Input } from "@app/components/ui/input";
import { Checkbox } from "@app/components/ui/checkbox";
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { InfoIcon, ExternalLink } from "lucide-react";
import { StrategySelect } from "@app/components/StrategySelect";
import { SwitchInput } from "@app/components/SwitchInput";
import { Badge } from "@app/components/ui/badge";
import { useTranslations } from "next-intl";
type CreateIdpFormValues = {
name: string;
type: "oidc";
clientId: string;
clientSecret: string;
authUrl: string;
tokenUrl: string;
identifierPath: string;
emailPath?: string;
namePath?: string;
scopes: string;
autoProvision: boolean;
};
type IdpCreateWizardProps = {
onSubmit: (data: CreateIdpFormValues) => void | Promise<void>;
defaultValues?: Partial<CreateIdpFormValues>;
loading?: boolean;
};
export function IdpCreateWizard({ onSubmit, defaultValues, loading = false }: IdpCreateWizardProps) {
const t = useTranslations();
const createIdpFormSchema = z.object({
name: z.string().min(2, { message: t('nameMin', {len: 2}) }),
type: z.enum(["oidc"]),
clientId: z.string().min(1, { message: t('idpClientIdRequired') }),
clientSecret: z.string().min(1, { message: t('idpClientSecretRequired') }),
authUrl: z.string().url({ message: t('idpErrorAuthUrlInvalid') }),
tokenUrl: z.string().url({ message: t('idpErrorTokenUrlInvalid') }),
identifierPath: z
.string()
.min(1, { message: t('idpPathRequired') }),
emailPath: z.string().optional(),
namePath: z.string().optional(),
scopes: z.string().min(1, { message: t('idpScopeRequired') }),
autoProvision: z.boolean().default(false)
});
interface ProviderTypeOption {
id: "oidc";
title: string;
description: string;
}
const providerTypes: ReadonlyArray<ProviderTypeOption> = [
{
id: "oidc",
title: "OAuth2/OIDC",
description: t('idpOidcDescription')
}
];
const form = useForm<CreateIdpFormValues>({
resolver: zodResolver(createIdpFormSchema),
defaultValues: {
name: "",
type: "oidc",
clientId: "",
clientSecret: "",
authUrl: "",
tokenUrl: "",
identifierPath: "sub",
namePath: "name",
emailPath: "email",
scopes: "openid profile email",
autoProvision: false,
...defaultValues
}
});
const handleSubmit = (data: CreateIdpFormValues) => {
onSubmit(data);
};
return (
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t('idpTitle')}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t('idpCreateSettingsDescription')}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...form}>
<form
className="space-y-4"
id="create-idp-form"
onSubmit={form.handleSubmit(handleSubmit)}
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>{t('name')}</FormLabel>
<FormControl>
<Input {...field} disabled={loading} />
</FormControl>
<FormDescription>
{t('idpDisplayName')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="flex items-start mb-0">
<SwitchInput
id="auto-provision-toggle"
label={t('idpAutoProvisionUsers')}
defaultChecked={form.getValues(
"autoProvision"
)}
onCheckedChange={(checked) => {
form.setValue(
"autoProvision",
checked
);
}}
disabled={loading}
/>
</div>
<span className="text-sm text-muted-foreground">
{t('idpAutoProvisionUsersDescription')}
</span>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t('idpType')}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t('idpTypeDescription')}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<StrategySelect
options={providerTypes}
defaultValue={form.getValues("type")}
onChange={(value) => {
form.setValue("type", value as "oidc");
}}
cols={3}
/>
</SettingsSectionBody>
</SettingsSection>
{form.watch("type") === "oidc" && (
<SettingsSectionGrid cols={2}>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t('idpOidcConfigure')}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t('idpOidcConfigureDescription')}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<Form {...form}>
<form
className="space-y-4"
id="create-idp-form"
onSubmit={form.handleSubmit(handleSubmit)}
>
<FormField
control={form.control}
name="clientId"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('idpClientId')}
</FormLabel>
<FormControl>
<Input {...field} disabled={loading} />
</FormControl>
<FormDescription>
{t('idpClientIdDescription')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="clientSecret"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('idpClientSecret')}
</FormLabel>
<FormControl>
<Input
type="password"
{...field}
disabled={loading}
/>
</FormControl>
<FormDescription>
{t('idpClientSecretDescription')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="authUrl"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('idpAuthUrl')}
</FormLabel>
<FormControl>
<Input
placeholder="https://your-idp.com/oauth2/authorize"
{...field}
disabled={loading}
/>
</FormControl>
<FormDescription>
{t('idpAuthUrlDescription')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="tokenUrl"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('idpTokenUrl')}
</FormLabel>
<FormControl>
<Input
placeholder="https://your-idp.com/oauth2/token"
{...field}
disabled={loading}
/>
</FormControl>
<FormDescription>
{t('idpTokenUrlDescription')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
<Alert variant="neutral">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
{t('idpOidcConfigureAlert')}
</AlertTitle>
<AlertDescription>
{t('idpOidcConfigureAlertDescription')}
</AlertDescription>
</Alert>
</SettingsSectionBody>
</SettingsSection>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t('idpToken')}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t('idpTokenDescription')}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<Form {...form}>
<form
className="space-y-4"
id="create-idp-form"
onSubmit={form.handleSubmit(handleSubmit)}
>
<Alert variant="neutral">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
{t('idpJmespathAbout')}
</AlertTitle>
<AlertDescription>
{t('idpJmespathAboutDescription')}{" "}
<a
href="https://jmespath.org"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center"
>
{t('idpJmespathAboutDescriptionLink')}{" "}
<ExternalLink className="ml-1 h-4 w-4" />
</a>
</AlertDescription>
</Alert>
<FormField
control={form.control}
name="identifierPath"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('idpJmespathLabel')}
</FormLabel>
<FormControl>
<Input {...field} disabled={loading} />
</FormControl>
<FormDescription>
{t('idpJmespathLabelDescription')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="emailPath"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('idpJmespathEmailPathOptional')}
</FormLabel>
<FormControl>
<Input {...field} disabled={loading} />
</FormControl>
<FormDescription>
{t('idpJmespathEmailPathOptionalDescription')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="namePath"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('idpJmespathNamePathOptional')}
</FormLabel>
<FormControl>
<Input {...field} disabled={loading} />
</FormControl>
<FormDescription>
{t('idpJmespathNamePathOptionalDescription')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="scopes"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('idpOidcConfigureScopes')}
</FormLabel>
<FormControl>
<Input {...field} disabled={loading} />
</FormControl>
<FormDescription>
{t('idpOidcConfigureScopesDescription')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</SettingsSectionBody>
</SettingsSection>
</SettingsSectionGrid>
)}
</SettingsContainer>
);
}

View File

@@ -0,0 +1,31 @@
"use client";
import {
ColumnDef,
} from "@tanstack/react-table";
import { DataTable } from "@app/components/ui/data-table";
import { useTranslations } from 'next-intl';
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
}
export function InvitationsDataTable<TData, TValue>({
columns,
data
}: DataTableProps<TData, TValue>) {
const t = useTranslations();
return (
<DataTable
columns={columns}
data={data}
persistPageSize="invitations-table"
title={t('invite')}
searchPlaceholder={t('inviteSearch')}
searchColumn="email"
/>
);
}

View File

@@ -0,0 +1,191 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu";
import { Button } from "@app/components/ui/button";
import { MoreHorizontal } from "lucide-react";
import { InvitationsDataTable } from "@app/components/InvitationsDataTable";
import { useState } from "react";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import RegenerateInvitationForm from "@app/components/RegenerateInvitationForm";
import { useOrgContext } from "@app/hooks/useOrgContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useTranslations } from "next-intl";
import moment from "moment";
export type InvitationRow = {
id: string;
email: string;
expiresAt: string;
role: string;
roleId: number;
};
type InvitationsTableProps = {
invitations: InvitationRow[];
};
export default function InvitationsTable({
invitations: i
}: InvitationsTableProps) {
const [invitations, setInvitations] = useState<InvitationRow[]>(i);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isRegenerateModalOpen, setIsRegenerateModalOpen] = useState(false);
const [selectedInvitation, setSelectedInvitation] =
useState<InvitationRow | null>(null);
const t = useTranslations();
const api = createApiClient(useEnvContext());
const { org } = useOrgContext();
const columns: ColumnDef<InvitationRow>[] = [
{
accessorKey: "email",
header: t("email")
},
{
accessorKey: "expiresAt",
header: t("expiresAt"),
cell: ({ row }) => {
const expiresAt = new Date(row.original.expiresAt);
const isExpired = expiresAt < new Date();
return (
<span className={isExpired ? "text-red-500" : ""}>
{moment(expiresAt).format("lll")}
</span>
);
}
},
{
accessorKey: "role",
header: t("role")
},
{
id: "dots",
cell: ({ row }) => {
const invitation = row.original;
return (
<div className="flex items-center justify-end gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">
{t("openMenu")}
</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
setIsDeleteModalOpen(true);
setSelectedInvitation(invitation);
}}
>
<span className="text-red-500">
{t("inviteRemove")}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant={"secondary"}
onClick={() => {
setIsRegenerateModalOpen(true);
setSelectedInvitation(invitation);
}}
>
<span>{t("inviteRegenerate")}</span>
</Button>
</div>
);
}
}
];
async function removeInvitation() {
if (selectedInvitation) {
const res = await api
.delete(
`/org/${org?.org.orgId}/invitations/${selectedInvitation.id}`
)
.catch((e) => {
toast({
variant: "destructive",
title: t("inviteRemoveError"),
description: t("inviteRemoveErrorDescription")
});
});
if (res && res.status === 200) {
toast({
variant: "default",
title: t("inviteRemoved"),
description: t("inviteRemovedDescription", {
email: selectedInvitation.email
})
});
setInvitations((prev) =>
prev.filter(
(invitation) => invitation.id !== selectedInvitation.id
)
);
}
}
setIsDeleteModalOpen(false);
}
return (
<>
<ConfirmDeleteDialog
open={isDeleteModalOpen}
setOpen={(val) => {
setIsDeleteModalOpen(val);
setSelectedInvitation(null);
}}
dialog={
<div className="space-y-4">
<p>
{t("inviteQuestionRemove", {
email: selectedInvitation?.email || ""
})}
</p>
<p>{t("inviteMessageRemove")}</p>
<p>{t("inviteMessageConfirm")}</p>
</div>
}
buttonText={t("inviteRemoveConfirm")}
onConfirm={removeInvitation}
string={selectedInvitation?.email ?? ""}
title={t("inviteRemove")}
/>
<RegenerateInvitationForm
open={isRegenerateModalOpen}
setOpen={setIsRegenerateModalOpen}
invitation={selectedInvitation}
onRegenerate={(updatedInvitation) => {
setInvitations((prev) =>
prev.map((inv) =>
inv.id === updatedInvitation.id
? updatedInvitation
: inv
)
);
}}
/>
<InvitationsDataTable columns={columns} data={invitations} />
</>
);
}

View File

@@ -0,0 +1,129 @@
"use client";
import { createApiClient } from "@app/lib/api";
import { Button } from "@app/components/ui/button";
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from "@app/components/ui/card";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { XCircle } from "lucide-react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
type InviteStatusCardProps = {
type: "rejected" | "wrong_user" | "user_does_not_exist" | "not_logged_in";
token: string;
email?: string;
};
export default function InviteStatusCard({
type,
token,
email,
}: InviteStatusCardProps) {
const router = useRouter();
const api = createApiClient(useEnvContext());
const t = useTranslations();
async function goToLogin() {
await api.post("/auth/logout", {});
const redirectUrl = email
? `/auth/login?redirect=/invite?token=${token}&email=${encodeURIComponent(email)}`
: `/auth/login?redirect=/invite?token=${token}`;
router.push(redirectUrl);
}
async function goToSignup() {
await api.post("/auth/logout", {});
const redirectUrl = email
? `/auth/signup?redirect=/invite?token=${token}&email=${encodeURIComponent(email)}`
: `/auth/signup?redirect=/invite?token=${token}`;
router.push(redirectUrl);
}
function renderBody() {
if (type === "rejected") {
return (
<div>
<p className="text-center mb-4">
{t('inviteErrorNotValid')}
</p>
<ul className="list-disc list-inside text-sm space-y-2">
<li>{t('inviteErrorExpired')}</li>
<li>{t('inviteErrorRevoked')}</li>
<li>{t('inviteErrorTypo')}</li>
</ul>
</div>
);
} else if (type === "wrong_user") {
return (
<div>
<p className="text-center mb-4">
{t('inviteErrorUser')}
</p>
<p className="text-center">
{t('inviteLoginUser')}
</p>
</div>
);
} else if (type === "user_does_not_exist") {
return (
<div>
<p className="text-center mb-4">
{t('inviteErrorNoUser')}
</p>
<p className="text-center">
{t('inviteCreateUser')}
</p>
</div>
);
}
}
function renderFooter() {
if (type === "rejected") {
return (
<Button
onClick={() => {
router.push("/");
}}
>
{t('goHome')}
</Button>
);
} else if (type === "wrong_user") {
return (
<Button onClick={goToLogin}>{t('inviteLogInOtherUser')}</Button>
);
} else if (type === "user_does_not_exist") {
return <Button onClick={goToSignup}>{t('createAnAccount')}</Button>;
}
}
return (
<div className="p-3 md:mt-32 flex items-center justify-center">
<Card className="w-full max-w-md">
<CardHeader>
{/* <div className="flex items-center justify-center w-20 h-20 rounded-full bg-red-100 mx-auto mb-4">
<XCircle
className="w-10 h-10 text-red-600"
aria-hidden="true"
/>
</div> */}
<CardTitle className="text-center text-2xl font-bold">
{t('inviteNotAccepted')}
</CardTitle>
</CardHeader>
<CardContent>{renderBody()}</CardContent>
<CardFooter className="flex justify-center space-x-4">
{renderFooter()}
</CardFooter>
</Card>
</div>
);
}

View File

@@ -0,0 +1,147 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { DataTable } from "@app/components/ui/data-table";
import { Button } from "@app/components/ui/button";
import { Badge } from "@app/components/ui/badge";
import { LicenseKeyCache } from "@server/license/license";
import { ArrowUpDown } from "lucide-react";
import moment from "moment";
import CopyToClipboard from "@app/components/CopyToClipboard";
import { useTranslations } from "next-intl";
type LicenseKeysDataTableProps = {
licenseKeys: LicenseKeyCache[];
onDelete: (key: LicenseKeyCache) => void;
onCreate: () => void;
};
function obfuscateLicenseKey(key: string): string {
if (key.length <= 8) return key;
const firstPart = key.substring(0, 4);
const lastPart = key.substring(key.length - 4);
return `${firstPart}••••••••••••••••••••${lastPart}`;
}
export function LicenseKeysDataTable({
licenseKeys,
onDelete,
onCreate
}: LicenseKeysDataTableProps) {
const t = useTranslations();
const columns: ColumnDef<LicenseKeyCache>[] = [
{
accessorKey: "licenseKey",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t('licenseKey')}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const licenseKey = row.original.licenseKey;
return (
<CopyToClipboard
text={licenseKey}
displayText={obfuscateLicenseKey(licenseKey)}
/>
);
}
},
{
accessorKey: "valid",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t('valid')}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
return row.original.valid ? t('yes') : t('no');
}
},
{
accessorKey: "type",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t('type')}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const type = row.original.type;
const label =
type === "SITES" ? t('sitesAdditional') : t('licenseHost');
const variant = type === "SITES" ? "secondary" : "default";
return row.original.valid ? (
<Badge variant={variant}>{label}</Badge>
) : null;
}
},
{
accessorKey: "numSites",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t('numberOfSites')}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
id: "delete",
cell: ({ row }) => (
<div className="flex items-center justify-end space-x-2">
<Button
variant="secondary"
onClick={() => onDelete(row.original)}
>
{t('delete')}
</Button>
</div>
)
}
];
return (
<DataTable
columns={columns}
data={licenseKeys}
persistPageSize="licenseKeys-table"
title={t('licenseKeys')}
searchPlaceholder={t('licenseKeySearch')}
searchColumn="licenseKey"
onAdd={onCreate}
addButtonText={t('licenseKeyAdd')}
/>
);
}

View File

@@ -0,0 +1,718 @@
"use client";
import { useState, useEffect } from "react";
import { useTranslations } from "next-intl";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@/components/ui/select";
import {
ExternalLink,
Globe,
Search,
RefreshCw,
AlertCircle,
ChevronLeft,
ChevronRight,
Key,
KeyRound,
Fingerprint,
AtSign,
Copy,
InfoIcon,
Combine
} from "lucide-react";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { GetUserResourcesResponse } from "@server/routers/resource/getUserResources";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { useToast } from "@app/hooks/useToast";
import { InfoPopup } from "@/components/ui/info-popup";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from "@/components/ui/tooltip";
// Update Resource type to include site information
type Resource = {
resourceId: number;
name: string;
domain: string;
enabled: boolean;
protected: boolean;
protocol: string;
// Auth method fields
sso?: boolean;
password?: boolean;
pincode?: boolean;
whitelist?: boolean;
// Site information
siteName?: string | null;
};
type MemberResourcesPortalProps = {
orgId: string;
};
// Favicon component with fallback
const ResourceFavicon = ({
domain,
enabled
}: {
domain: string;
enabled: boolean;
}) => {
const [faviconError, setFaviconError] = useState(false);
const [faviconLoaded, setFaviconLoaded] = useState(false);
// Extract domain for favicon URL
const cleanDomain = domain.replace(/^https?:\/\//, "").split("/")[0];
const faviconUrl = `https://www.google.com/s2/favicons?domain=${cleanDomain}&sz=32`;
const handleFaviconLoad = () => {
setFaviconLoaded(true);
setFaviconError(false);
};
const handleFaviconError = () => {
setFaviconError(true);
setFaviconLoaded(false);
};
if (faviconError || !enabled) {
return (
<Globe className="h-4 w-4 text-muted-foreground flex-shrink-0" />
);
}
return (
<div className="relative h-4 w-4 flex-shrink-0">
{!faviconLoaded && (
<div className="absolute inset-0 bg-muted animate-pulse rounded-sm"></div>
)}
<img
src={faviconUrl}
alt={`${cleanDomain} favicon`}
className={`h-4 w-4 rounded-sm transition-opacity ${faviconLoaded ? "opacity-100" : "opacity-0"}`}
onLoad={handleFaviconLoad}
onError={handleFaviconError}
/>
</div>
);
};
// Resource Info component
const ResourceInfo = ({ resource }: { resource: Resource }) => {
const hasAuthMethods =
resource.sso ||
resource.password ||
resource.pincode ||
resource.whitelist;
const infoContent = (
<div className="flex flex-col gap-3">
{/* Site Information */}
{resource.siteName && (
<div>
<div className="text-xs font-medium mb-1.5">Site</div>
<div className="flex items-center gap-2">
<Combine className="h-4 w-4 text-foreground shrink-0" />
<span className="text-sm">{resource.siteName}</span>
</div>
</div>
)}
{/* Authentication Methods */}
{hasAuthMethods && (
<div
className={
resource.siteName ? "border-t border-border pt-2" : ""
}
>
<div className="text-xs font-medium mb-1.5">
Authentication Methods
</div>
<div className="flex flex-col gap-1.5">
{resource.sso && (
<div className="flex items-center gap-2">
<div className="h-5 w-5 rounded-full flex items-center justify-center bg-blue-50/50 dark:bg-blue-950/50">
<Key className="h-3 w-3 text-blue-700 dark:text-blue-300" />
</div>
<span className="text-sm">
Single Sign-On (SSO)
</span>
</div>
)}
{resource.password && (
<div className="flex items-center gap-2">
<div className="h-5 w-5 rounded-full flex items-center justify-center bg-purple-50/50 dark:bg-purple-950/50">
<KeyRound className="h-3 w-3 text-purple-700 dark:text-purple-300" />
</div>
<span className="text-sm">
Password Protected
</span>
</div>
)}
{resource.pincode && (
<div className="flex items-center gap-2">
<div className="h-5 w-5 rounded-full flex items-center justify-center bg-emerald-50/50 dark:bg-emerald-950/50">
<Fingerprint className="h-3 w-3 text-emerald-700 dark:text-emerald-300" />
</div>
<span className="text-sm">PIN Code</span>
</div>
)}
{resource.whitelist && (
<div className="flex items-center gap-2">
<div className="h-5 w-5 rounded-full flex items-center justify-center bg-amber-50/50 dark:bg-amber-950/50">
<AtSign className="h-3 w-3 text-amber-700 dark:text-amber-300" />
</div>
<span className="text-sm">Email Whitelist</span>
</div>
)}
</div>
</div>
)}
{/* Resource Status - if disabled */}
{!resource.enabled && (
<div
className={`${resource.siteName || hasAuthMethods ? "border-t border-border pt-2" : ""}`}
>
<div className="flex items-center gap-2">
<AlertCircle className="h-4 w-4 text-destructive shrink-0" />
<span className="text-sm text-destructive">
Resource Disabled
</span>
</div>
</div>
)}
</div>
);
return <InfoPopup>{infoContent}</InfoPopup>;
};
// Pagination component
const PaginationControls = ({
currentPage,
totalPages,
onPageChange,
totalItems,
itemsPerPage
}: {
currentPage: number;
totalPages: number;
onPageChange: (page: number) => void;
totalItems: number;
itemsPerPage: number;
}) => {
const startItem = (currentPage - 1) * itemsPerPage + 1;
const endItem = Math.min(currentPage * itemsPerPage, totalItems);
if (totalPages <= 1) return null;
return (
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 mt-8">
<div className="text-sm text-muted-foreground">
Showing {startItem}-{endItem} of {totalItems} resources
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 1}
className="gap-1"
>
<ChevronLeft className="h-4 w-4" />
Previous
</Button>
<div className="flex items-center gap-1">
{Array.from({ length: totalPages }, (_, i) => i + 1).map(
(page) => {
// Show first page, last page, current page, and 2 pages around current
const showPage =
page === 1 ||
page === totalPages ||
Math.abs(page - currentPage) <= 1;
const showEllipsis =
(page === 2 && currentPage > 4) ||
(page === totalPages - 1 &&
currentPage < totalPages - 3);
if (!showPage && !showEllipsis) return null;
if (showEllipsis) {
return (
<span
key={page}
className="px-2 text-muted-foreground"
>
...
</span>
);
}
return (
<Button
key={page}
variant={
currentPage === page
? "default"
: "outline"
}
size="sm"
onClick={() => onPageChange(page)}
className="w-8 h-8 p-0"
>
{page}
</Button>
);
}
)}
</div>
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage === totalPages}
className="gap-1"
>
Next
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
);
};
// Loading skeleton component
const ResourceCardSkeleton = () => (
<Card className="rounded-lg bg-card text-card-foreground flex flex-col w-full animate-pulse">
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="h-6 bg-muted rounded w-3/4"></div>
<div className="h-5 bg-muted rounded w-16"></div>
</div>
</CardHeader>
<CardContent className="px-6 pb-6 flex-1 flex flex-col justify-between">
<div className="space-y-3">
<div className="flex items-center space-x-2">
<div className="h-4 w-4 bg-muted rounded"></div>
<div className="h-4 bg-muted rounded w-1/2"></div>
</div>
<div className="flex items-center space-x-2">
<div className="h-4 w-4 bg-muted rounded"></div>
<div className="h-4 bg-muted rounded w-1/3"></div>
</div>
</div>
<div className="mt-4">
<div className="h-8 bg-muted rounded w-full"></div>
</div>
</CardContent>
</Card>
);
export default function MemberResourcesPortal({
orgId
}: MemberResourcesPortalProps) {
const t = useTranslations();
const { env } = useEnvContext();
const api = createApiClient({ env });
const { toast } = useToast();
const [resources, setResources] = useState<Resource[]>([]);
const [filteredResources, setFilteredResources] = useState<Resource[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const [sortBy, setSortBy] = useState("name-asc");
const [refreshing, setRefreshing] = useState(false);
// Pagination state
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 12; // 3x4 grid on desktop
const fetchUserResources = async (isRefresh = false) => {
try {
if (isRefresh) {
setRefreshing(true);
} else {
setLoading(true);
}
setError(null);
const response = await api.get<GetUserResourcesResponse>(
`/org/${orgId}/user-resources`
);
if (response.data.success) {
setResources(response.data.data.resources);
setFilteredResources(response.data.data.resources);
} else {
setError("Failed to load resources");
}
} catch (err) {
console.error("Error fetching user resources:", err);
setError(
"Failed to load resources. Please check your connection and try again."
);
} finally {
setLoading(false);
setRefreshing(false);
}
};
useEffect(() => {
fetchUserResources();
}, [orgId, api]);
// Filter and sort resources
useEffect(() => {
const filtered = resources.filter(
(resource) =>
resource.name
.toLowerCase()
.includes(searchQuery.toLowerCase()) ||
resource.domain
.toLowerCase()
.includes(searchQuery.toLowerCase())
);
// Sort resources
filtered.sort((a, b) => {
switch (sortBy) {
case "name-asc":
return a.name.localeCompare(b.name);
case "name-desc":
return b.name.localeCompare(a.name);
case "domain-asc":
return a.domain.localeCompare(b.domain);
case "domain-desc":
return b.domain.localeCompare(a.domain);
case "status-enabled":
// Enabled first, then protected vs unprotected
if (a.enabled !== b.enabled) return b.enabled ? 1 : -1;
return b.protected ? 1 : -1;
case "status-disabled":
// Disabled first, then unprotected vs protected
if (a.enabled !== b.enabled) return a.enabled ? 1 : -1;
return a.protected ? 1 : -1;
default:
return a.name.localeCompare(b.name);
}
});
setFilteredResources(filtered);
// Reset to first page when search/sort changes
setCurrentPage(1);
}, [resources, searchQuery, sortBy]);
// Calculate pagination
const totalPages = Math.ceil(filteredResources.length / itemsPerPage);
const startIndex = (currentPage - 1) * itemsPerPage;
const paginatedResources = filteredResources.slice(
startIndex,
startIndex + itemsPerPage
);
const handleOpenResource = (resource: Resource) => {
// Open the resource in a new tab
window.open(resource.domain, "_blank");
};
const handleRefresh = () => {
fetchUserResources(true);
};
const handleRetry = () => {
fetchUserResources();
};
const handlePageChange = (page: number) => {
setCurrentPage(page);
// Scroll to top when page changes
window.scrollTo({ top: 0, behavior: "smooth" });
};
if (loading) {
return (
<div className="container mx-auto max-w-12xl">
<SettingsSectionTitle
title="Resources"
description="Resources you have access to in this organization"
/>
{/* Search and Sort Controls - Skeleton */}
<div className="mb-6 flex flex-col sm:flex-row gap-4 justify-start">
<div className="relative w-full sm:w-80">
<div className="h-10 bg-muted rounded animate-pulse"></div>
</div>
<div className="w-full sm:w-36">
<div className="h-10 bg-muted rounded animate-pulse"></div>
</div>
</div>
{/* Loading Skeletons */}
<div className="grid gap-4 sm:gap-6 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 auto-cols-fr">
{Array.from({ length: 12 }).map((_, index) => (
<ResourceCardSkeleton key={index} />
))}
</div>
</div>
);
}
if (error) {
return (
<div className="container mx-auto max-w-12xl">
<SettingsSectionTitle
title="Resources"
description="Resources you have access to in this organization"
/>
<Card>
<CardContent className="flex flex-col items-center justify-center py-20 text-center">
<div className="mb-6">
<AlertCircle className="h-16 w-16 text-destructive/60" />
</div>
<h3 className="text-xl font-semibold text-foreground mb-3">
Unable to Load Resources
</h3>
<p className="text-muted-foreground max-w-lg text-base mb-6">
{error}
</p>
<Button
onClick={handleRetry}
variant="outline"
className="gap-2"
>
<RefreshCw className="h-4 w-4" />
Try Again
</Button>
</CardContent>
</Card>
</div>
);
}
return (
<div className="container mx-auto max-w-12xl">
<SettingsSectionTitle
title="Resources"
description="Resources you have access to in this organization"
/>
{/* Search and Sort Controls with Refresh */}
<div className="mb-6 flex flex-col sm:flex-row gap-4 justify-between items-start">
<div className="flex flex-col sm:flex-row gap-4 justify-start flex-1">
{/* Search */}
<div className="relative w-full sm:w-80">
<Input
placeholder="Search resources..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-8 bg-card"
/>
<Search className="h-4 w-4 absolute left-2 top-1/2 transform -translate-y-1/2 text-muted-foreground" />
</div>
{/* Sort */}
<div className="w-full sm:w-36">
<Select value={sortBy} onValueChange={setSortBy}>
<SelectTrigger className="bg-card">
<SelectValue placeholder="Sort by..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="name-asc">
Name A-Z
</SelectItem>
<SelectItem value="name-desc">
Name Z-A
</SelectItem>
<SelectItem value="domain-asc">
Domain A-Z
</SelectItem>
<SelectItem value="domain-desc">
Domain Z-A
</SelectItem>
<SelectItem value="status-enabled">
Enabled First
</SelectItem>
<SelectItem value="status-disabled">
Disabled First
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* Refresh Button */}
<Button
onClick={handleRefresh}
variant="outline"
size="sm"
disabled={refreshing}
className="gap-2 shrink-0"
>
<RefreshCw
className={`h-4 w-4 ${refreshing ? "animate-spin" : ""}`}
/>
Refresh
</Button>
</div>
{/* Resources Content */}
{filteredResources.length === 0 ? (
/* Enhanced Empty State */
<Card>
<CardContent className="flex flex-col items-center justify-center py-20 text-center">
<div className="mb-8 p-4 rounded-full bg-muted/20 dark:bg-muted/30">
{searchQuery ? (
<Search className="h-12 w-12 text-muted-foreground/70" />
) : (
<Globe className="h-12 w-12 text-muted-foreground/70" />
)}
</div>
<h3 className="text-2xl font-semibold text-foreground mb-3">
{searchQuery
? "No Resources Found"
: "No Resources Available"}
</h3>
<p className="text-muted-foreground max-w-lg text-base mb-6">
{searchQuery
? `No resources match "${searchQuery}". Try adjusting your search terms or clearing the search to see all resources.`
: "You don't have access to any resources yet. Contact your administrator to get access to resources you need."}
</p>
<div className="flex flex-col sm:flex-row gap-3">
{searchQuery ? (
<Button
onClick={() => setSearchQuery("")}
variant="outline"
className="gap-2"
>
Clear Search
</Button>
) : (
<Button
onClick={handleRefresh}
variant="outline"
disabled={refreshing}
className="gap-2"
>
<RefreshCw
className={`h-4 w-4 ${refreshing ? "animate-spin" : ""}`}
/>
Refresh Resources
</Button>
)}
</div>
</CardContent>
</Card>
) : (
<>
{/* Resources Grid */}
<div className="grid gap-5 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-3 2xl:grid-cols-4 auto-cols-fr">
{paginatedResources.map((resource) => (
<Card key={resource.resourceId}>
<div className="p-6">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center min-w-0 flex-1 gap-3 overflow-hidden">
<TooltipProvider>
<Tooltip>
<TooltipTrigger className="min-w-0 max-w-full">
<CardTitle className="text-lg font-bold text-foreground truncate group-hover:text-primary transition-colors">
{resource.name}
</CardTitle>
</TooltipTrigger>
<TooltipContent>
<p className="max-w-xs break-words">
{resource.name}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="flex-shrink-0">
<ResourceInfo resource={resource} />
</div>
</div>
<div className="flex items-center gap-2 mt-3">
<button
onClick={() =>
handleOpenResource(resource)
}
className="text-sm text-muted-foreground font-medium text-left truncate flex-1"
disabled={!resource.enabled}
>
{resource.domain.replace(
/^https?:\/\//,
""
)}
</button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground"
onClick={() => {
navigator.clipboard.writeText(
resource.domain
);
toast({
title: "Copied to clipboard",
description:
"Resource URL has been copied to your clipboard.",
duration: 2000
});
}}
>
<Copy className="h-4 w-4" />
</Button>
</div>
</div>
<div className="p-6 pt-0 mt-auto">
<Button
onClick={() =>
handleOpenResource(resource)
}
className="w-full h-9 transition-all group-hover:shadow-sm"
variant="outline"
size="sm"
disabled={!resource.enabled}
>
<ExternalLink className="h-3.5 w-3.5 mr-2" />
Open Resource
</Button>
</div>
</Card>
))}
</div>
{/* Pagination Controls */}
<PaginationControls
currentPage={currentPage}
totalPages={totalPages}
onPageChange={handlePageChange}
totalItems={filteredResources.length}
itemsPerPage={itemsPerPage}
/>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,33 @@
"use client";
import { DataTable } from "@app/components/ui/data-table";
import { ColumnDef } from "@tanstack/react-table";
import { useTranslations } from "next-intl";
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
addApiKey?: () => void;
}
export function OrgApiKeysDataTable<TData, TValue>({
addApiKey,
columns,
data
}: DataTableProps<TData, TValue>) {
const t = useTranslations();
return (
<DataTable
columns={columns}
data={data}
persistPageSize="Org-apikeys-table"
title={t('apiKeys')}
searchPlaceholder={t('searchApiKeys')}
searchColumn="name"
onAdd={addApiKey}
addButtonText={t('apiKeysAdd')}
/>
);
}

View File

@@ -0,0 +1,199 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { OrgApiKeysDataTable } from "@app/components/OrgApiKeysDataTable";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu";
import { Button } from "@app/components/ui/button";
import { ArrowRight, ArrowUpDown, MoreHorizontal } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { toast } from "@app/hooks/useToast";
import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import moment from "moment";
import { useTranslations } from "next-intl";
export type OrgApiKeyRow = {
id: string;
key: string;
name: string;
createdAt: string;
};
type OrgApiKeyTableProps = {
apiKeys: OrgApiKeyRow[];
orgId: string;
};
export default function OrgApiKeysTable({
apiKeys,
orgId
}: OrgApiKeyTableProps) {
const router = useRouter();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selected, setSelected] = useState<OrgApiKeyRow | null>(null);
const [rows, setRows] = useState<OrgApiKeyRow[]>(apiKeys);
const api = createApiClient(useEnvContext());
const t = useTranslations();
const deleteSite = (apiKeyId: string) => {
api.delete(`/org/${orgId}/api-key/${apiKeyId}`)
.catch((e) => {
console.error(t("apiKeysErrorDelete"), e);
toast({
variant: "destructive",
title: t("apiKeysErrorDelete"),
description: formatAxiosError(
e,
t("apiKeysErrorDeleteMessage")
)
});
})
.then(() => {
router.refresh();
setIsDeleteModalOpen(false);
const newRows = rows.filter((row) => row.id !== apiKeyId);
setRows(newRows);
});
};
const columns: ColumnDef<OrgApiKeyRow>[] = [
{
accessorKey: "name",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("name")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "key",
header: t("key"),
cell: ({ row }) => {
const r = row.original;
return <span className="font-mono">{r.key}</span>;
}
},
{
accessorKey: "createdAt",
header: t("createdAt"),
cell: ({ row }) => {
const r = row.original;
return <span>{moment(r.createdAt).format("lll")}</span>;
}
},
{
id: "actions",
cell: ({ row }) => {
const r = row.original;
return (
<div className="flex items-center justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">
{t("openMenu")}
</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
setSelected(r);
}}
>
<span>{t("viewSettings")}</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setSelected(r);
setIsDeleteModalOpen(true);
}}
>
<span className="text-red-500">
{t("delete")}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Link href={`/${orgId}/settings/api-keys/${r.id}`}>
<Button
variant={"secondary"}
className="ml-2"
size="sm"
>
{t("edit")}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</Link>
</div>
);
}
}
];
return (
<>
{selected && (
<ConfirmDeleteDialog
open={isDeleteModalOpen}
setOpen={(val) => {
setIsDeleteModalOpen(val);
setSelected(null);
}}
dialog={
<div className="space-y-4">
<p>
{t("apiKeysQuestionRemove", {
selectedApiKey:
selected?.name || selected?.id
})}
</p>
<p>
<b>{t("apiKeysMessageRemove")}</b>
</p>
<p>{t("apiKeysMessageConfirm")}</p>
</div>
}
buttonText={t("apiKeysDeleteConfirm")}
onConfirm={async () => deleteSite(selected!.id)}
string={selected.name}
title={t("apiKeysDelete")}
/>
)}
<OrgApiKeysDataTable
columns={columns}
data={rows}
addApiKey={() => {
router.push(`/${orgId}/settings/api-keys/create`);
}}
/>
</>
);
}

View File

@@ -0,0 +1,105 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import {
Card,
CardHeader,
CardTitle,
CardContent,
CardFooter
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Users, Globe, Database, Cog, Settings, Waypoints, Combine } from "lucide-react";
import { useTranslations } from "next-intl";
interface OrgStat {
label: string;
value: number;
icon: React.ReactNode;
}
type OrganizationLandingCardProps = {
overview: {
orgName: string;
stats: {
sites: number;
resources: number;
users: number;
};
userRole: string;
isAdmin: boolean;
isOwner: boolean;
orgId: string;
};
};
export default function OrganizationLandingCard(
props: OrganizationLandingCardProps
) {
const [orgData] = useState(props);
const t = useTranslations();
const orgStats: OrgStat[] = [
{
label: t('sites'),
value: orgData.overview.stats.sites,
icon: <Combine className="h-6 w-6" />
},
{
label: t('resources'),
value: orgData.overview.stats.resources,
icon: <Waypoints className="h-6 w-6" />
},
{
label: t('users'),
value: orgData.overview.stats.users,
icon: <Users className="h-6 w-6" />
}
];
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center text-3xl font-bold">
{orgData.overview.orgName}
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
{orgStats.map((stat, index) => (
<div
key={index}
className="flex flex-col items-center p-4 bg-secondary rounded-lg"
>
{stat.icon}
<span className="mt-2 text-2xl font-bold">
{stat.value}
</span>
<span className="text-sm text-muted-foreground">
{stat.label}
</span>
</div>
))}
</div>
<div className="text-center text-lg">
{t('accessRoleYour')}{" "}
<span className="font-semibold">
{orgData.overview.isOwner ? t('accessRoleOwner') : orgData.overview.userRole}
</span>
</div>
</CardContent>
{orgData.overview.isAdmin && (
<CardFooter className="flex justify-center">
<Link href={`/${orgData.overview.orgId}/settings`}>
<Button size="lg" className="w-full md:w-auto">
<Settings className="mr-2 h-4 w-4" />
{t('orgGeneralSettings')}
</Button>
</Link>
</CardFooter>
)}
</Card>
);
}

View File

@@ -0,0 +1,33 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { DataTable } from "@app/components/ui/data-table";
import { useTranslations } from "next-intl";
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
onAdd: () => void;
}
export function PolicyDataTable<TData, TValue>({
columns,
data,
onAdd
}: DataTableProps<TData, TValue>) {
const t = useTranslations();
return (
<DataTable
columns={columns}
data={data}
persistPageSize="orgPolicies-table"
title={t('orgPolicies')}
searchPlaceholder={t('orgPoliciesSearch')}
searchColumn="orgId"
addButtonText={t('orgPoliciesAdd')}
onAdd={onAdd}
/>
);
}

View File

@@ -0,0 +1,156 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { Button } from "@app/components/ui/button";
import {
ArrowUpDown,
Trash2,
MoreHorizontal,
Pencil,
ArrowRight
} from "lucide-react";
import { PolicyDataTable } from "@app/components/PolicyDataTable";
import { Badge } from "@app/components/ui/badge";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu";
import Link from "next/link";
import { InfoPopup } from "@app/components/ui/info-popup";
import { useTranslations } from "next-intl";
export interface PolicyRow {
orgId: string;
roleMapping?: string;
orgMapping?: string;
}
interface Props {
policies: PolicyRow[];
onDelete: (orgId: string) => void;
onAdd: () => void;
onEdit: (policy: PolicyRow) => void;
}
export default function PolicyTable({ policies, onDelete, onAdd, onEdit }: Props) {
const t = useTranslations();
const columns: ColumnDef<PolicyRow>[] = [
{
id: "dots",
cell: ({ row }) => {
const r = row.original;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">{t('openMenu')}</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
onDelete(r.orgId);
}}
>
<span className="text-red-500">{t('delete')}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
},
{
accessorKey: "orgId",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t('orgId')}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "roleMapping",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t('roleMapping')}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const mapping = row.original.roleMapping;
return mapping ? (
<InfoPopup
text={mapping.length > 50 ? `${mapping.substring(0, 50)}...` : mapping}
info={mapping}
/>
) : (
"--"
);
}
},
{
accessorKey: "orgMapping",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t('orgMapping')}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const mapping = row.original.orgMapping;
return mapping ? (
<InfoPopup
text={mapping.length > 50 ? `${mapping.substring(0, 50)}...` : mapping}
info={mapping}
/>
) : (
"--"
);
}
},
{
id: "actions",
cell: ({ row }) => {
const policy = row.original;
return (
<div className="flex items-center justify-end">
<Button
variant={"secondary"}
className="ml-2"
onClick={() => onEdit(policy)}
>
{t('edit')}
</Button>
</div>
);
}
}
];
return <PolicyDataTable columns={columns} data={policies} onAdd={onAdd} />;
}

View File

@@ -0,0 +1,251 @@
import { Button } from "@app/components/ui/button";
import {
Credenza,
CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "@app/components/Credenza";
import { useState, useEffect } from "react";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useOrgContext } from "@app/hooks/useOrgContext";
import { toast } from "@app/hooks/useToast";
import CopyTextBox from "@app/components/CopyTextBox";
import { Checkbox } from "@app/components/ui/checkbox";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@app/components/ui/select";
import { Label } from "@app/components/ui/label";
import { useTranslations } from "next-intl";
type RegenerateInvitationFormProps = {
open: boolean;
setOpen: (open: boolean) => void;
invitation: {
id: string;
email: string;
roleId: number;
role: string;
} | null;
onRegenerate: (updatedInvitation: {
id: string;
email: string;
expiresAt: string;
role: string;
roleId: number;
}) => void;
};
export default function RegenerateInvitationForm({
open,
setOpen,
invitation,
onRegenerate
}: RegenerateInvitationFormProps) {
const [loading, setLoading] = useState(false);
const [inviteLink, setInviteLink] = useState<string | null>(null);
const [sendEmail, setSendEmail] = useState(true);
const [validHours, setValidHours] = useState(72);
const api = createApiClient(useEnvContext());
const { org } = useOrgContext();
const t = useTranslations();
const validForOptions = [
{ hours: 24, name: t('day', {count: 1}) },
{ hours: 48, name: t('day', {count: 2}) },
{ hours: 72, name: t('day', {count: 3}) },
{ hours: 96, name: t('day', {count: 4}) },
{ hours: 120, name: t('day', {count: 5}) },
{ hours: 144, name: t('day', {count: 6}) },
{ hours: 168, name: t('day', {count: 7}) }
];
useEffect(() => {
if (open) {
setSendEmail(true);
setValidHours(72);
}
}, [open]);
async function handleRegenerate() {
if (!invitation) return;
if (!org?.org.orgId) {
toast({
variant: "destructive",
title: t('orgMissing'),
description: t('orgMissingMessage'),
duration: 5000
});
return;
}
setLoading(true);
try {
const res = await api.post(`/org/${org.org.orgId}/create-invite`, {
email: invitation.email,
roleId: invitation.roleId,
validHours,
sendEmail,
regenerate: true
});
if (res.status === 200) {
const link = res.data.data.inviteLink;
setInviteLink(link);
if (sendEmail) {
toast({
variant: "default",
title: t('inviteRegenerated'),
description: t('inviteSent', {email: invitation.email}),
duration: 5000
});
} else {
toast({
variant: "default",
title: t('inviteRegenerated'),
description: t('inviteGenerate', {email: invitation.email}),
duration: 5000
});
}
onRegenerate({
id: invitation.id,
email: invitation.email,
expiresAt: res.data.data.expiresAt,
role: invitation.role,
roleId: invitation.roleId
});
}
} catch (error: any) {
if (error.response?.status === 409) {
toast({
variant: "destructive",
title: t('inviteDuplicateError'),
description: t('inviteDuplicateErrorDescription'),
duration: 5000
});
} else if (error.response?.status === 429) {
toast({
variant: "destructive",
title: t('inviteRateLimitError'),
description: t('inviteRateLimitErrorDescription'),
duration: 5000
});
} else {
toast({
variant: "destructive",
title: t('inviteRegenerateError'),
description: t('inviteRegenerateErrorDescription'),
duration: 5000
});
}
} finally {
setLoading(false);
}
}
return (
<Credenza
open={open}
onOpenChange={(isOpen) => {
setOpen(isOpen);
if (!isOpen) {
setInviteLink(null);
}
}}
>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>{t('inviteRegenerate')}</CredenzaTitle>
<CredenzaDescription>
{t('inviteRegenerateDescription')}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
{!inviteLink ? (
<div>
<p>
{t('inviteQuestionRegenerate', {email: invitation?.email || ""})}
</p>
<div className="flex items-center space-x-2 mt-4">
<Checkbox
id="send-email"
checked={sendEmail}
onCheckedChange={(e) =>
setSendEmail(e as boolean)
}
/>
<label htmlFor="send-email">
{t('inviteSentEmail')}
</label>
</div>
<div className="mt-4 space-y-2">
<Label>
{t('inviteValidityPeriod')}
</Label>
<Select
value={validHours.toString()}
onValueChange={(value) =>
setValidHours(parseInt(value))
}
>
<SelectTrigger className="w-full">
<SelectValue placeholder={t('inviteValidityPeriodSelect')} />
</SelectTrigger>
<SelectContent>
{validForOptions.map((option) => (
<SelectItem
key={option.hours}
value={option.hours.toString()}
>
{option.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
) : (
<div className="space-y-4 max-w-md">
<p>
{t('inviteRegenerateMessage')}
</p>
<CopyTextBox text={inviteLink} wrapText={false} />
</div>
)}
</CredenzaBody>
<CredenzaFooter>
{!inviteLink ? (
<>
<CredenzaClose asChild>
<Button variant="outline">{t('cancel')}</Button>
</CredenzaClose>
<Button
onClick={handleRegenerate}
loading={loading}
>
{t('inviteRegenerateButton')}
</Button>
</>
) : (
<CredenzaClose asChild>
<Button variant="outline">{t('close')}</Button>
</CredenzaClose>
)}
</CredenzaFooter>
</CredenzaContent>
</Credenza>
);
}

View File

@@ -0,0 +1,533 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from "@/components/ui/card";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
InputOTP,
InputOTPGroup,
InputOTPSlot
} from "@/components/ui/input-otp";
import { AxiosResponse } from "axios";
import {
RequestPasswordResetBody,
RequestPasswordResetResponse,
ResetPasswordBody,
ResetPasswordResponse
} from "@server/routers/auth";
import { Loader2 } from "lucide-react";
import { Alert, AlertDescription } from "./ui/alert";
import { toast } from "@app/hooks/useToast";
import { useRouter } from "next/navigation";
import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp";
import { passwordSchema } from "@server/auth/passwordSchema";
import { cleanRedirect } from "@app/lib/cleanRedirect";
import { useTranslations } from "next-intl";
const requestSchema = z.object({
email: z.string().email()
});
export type ResetPasswordFormProps = {
emailParam?: string;
tokenParam?: string;
redirect?: string;
quickstart?: boolean;
};
export default function ResetPasswordForm({
emailParam,
tokenParam,
redirect,
quickstart
}: ResetPasswordFormProps) {
const router = useRouter();
const [error, setError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const t = useTranslations();
function getState() {
if (emailParam && !tokenParam) {
return "request";
}
if (emailParam && tokenParam) {
return "reset";
}
return "request";
}
const [state, setState] = useState<"request" | "reset" | "mfa">(getState());
const api = createApiClient(useEnvContext());
const formSchema = z
.object({
email: z.string().email({ message: t('emailInvalid') }),
token: z.string().min(8, { message: t('tokenInvalid') }),
password: passwordSchema,
confirmPassword: passwordSchema
})
.refine((data) => data.password === data.confirmPassword, {
path: ["confirmPassword"],
message: t('passwordNotMatch')
});
const mfaSchema = z.object({
code: z.string().length(6, { message: t('pincodeInvalid') })
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
email: emailParam || "",
token: tokenParam || "",
password: "",
confirmPassword: ""
}
});
const mfaForm = useForm<z.infer<typeof mfaSchema>>({
resolver: zodResolver(mfaSchema),
defaultValues: {
code: ""
}
});
const requestForm = useForm<z.infer<typeof requestSchema>>({
resolver: zodResolver(requestSchema),
defaultValues: {
email: emailParam || ""
}
});
async function onRequest(data: z.infer<typeof requestSchema>) {
const { email } = data;
setIsSubmitting(true);
const res = await api
.post<AxiosResponse<RequestPasswordResetResponse>>(
"/auth/reset-password/request",
{
email
} as RequestPasswordResetBody
)
.catch((e) => {
setError(formatAxiosError(e, t('errorOccurred')));
console.error(t('passwordErrorRequestReset'), e);
setIsSubmitting(false);
});
if (res && res.data?.data) {
setError(null);
setState("reset");
setIsSubmitting(false);
form.setValue("email", email);
}
}
async function onReset(data: any) {
setIsSubmitting(true);
const { password, email, token } = form.getValues();
const { code } = mfaForm.getValues();
const res = await api
.post<AxiosResponse<ResetPasswordResponse>>(
"/auth/reset-password",
{
email,
token,
newPassword: password,
code
} as ResetPasswordBody
)
.catch((e) => {
setError(formatAxiosError(e, t('errorOccurred')));
console.error(t('passwordErrorReset'), e);
setIsSubmitting(false);
});
console.log(res);
if (res) {
setError(null);
if (res.data.data?.codeRequested) {
setState("mfa");
setIsSubmitting(false);
mfaForm.reset();
return;
}
setSuccessMessage(quickstart ? t('accountSetupSuccess') : t('passwordResetSuccess'));
// Auto-login after successful password reset
try {
const loginRes = await api.post("/auth/login", {
email: form.getValues("email"),
password: form.getValues("password")
});
if (loginRes.data.data?.codeRequested) {
if (redirect) {
router.push(`/auth/login?redirect=${redirect}`);
} else {
router.push("/auth/login");
}
return;
}
if (loginRes.data.data?.emailVerificationRequired) {
try {
await api.post("/auth/verify-email/request");
} catch (verificationError) {
console.error("Failed to send verification code:", verificationError);
}
if (redirect) {
router.push(`/auth/verify-email?redirect=${redirect}`);
} else {
router.push("/auth/verify-email");
}
return;
}
// Login successful, redirect
setTimeout(() => {
if (redirect) {
const safe = cleanRedirect(redirect);
router.push(safe);
} else {
router.push("/");
}
setIsSubmitting(false);
}, 1500);
} catch (loginError) {
// Auto-login failed, but password reset was successful
console.error("Auto-login failed:", loginError);
setTimeout(() => {
if (redirect) {
const safe = cleanRedirect(redirect);
router.push(safe);
} else {
router.push("/login");
}
setIsSubmitting(false);
}, 1500);
}
}
}
return (
<div>
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>
{quickstart ? t('completeAccountSetup') : t('passwordReset')}
</CardTitle>
<CardDescription>
{quickstart
? t('completeAccountSetupDescription')
: t('passwordResetDescription')
}
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{state === "request" && (
<Form {...requestForm}>
<form
onSubmit={requestForm.handleSubmit(
onRequest
)}
className="space-y-4"
id="form"
>
<FormField
control={requestForm.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>{t('email')}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
<FormDescription>
{quickstart
? t('accountSetupSent')
: t('passwordResetSent')
}
</FormDescription>
</FormItem>
)}
/>
</form>
</Form>
)}
{state === "reset" && (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onReset)}
className="space-y-4"
id="form"
>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>{t('email')}</FormLabel>
<FormControl>
<Input
{...field}
disabled
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{!tokenParam && (
<FormField
control={form.control}
name="token"
render={({ field }) => (
<FormItem>
<FormLabel>
{quickstart
? t('accountSetupCode')
: t('passwordResetCode')
}
</FormLabel>
<FormControl>
<Input
type="password"
{...field}
/>
</FormControl>
<FormMessage />
<FormDescription>
{quickstart
? t('accountSetupCodeDescription')
: t('passwordResetCodeDescription')
}
</FormDescription>
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>
{quickstart
? t('passwordCreate')
: t('passwordNew')
}
</FormLabel>
<FormControl>
<Input
type="password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="confirmPassword"
render={({ field }) => (
<FormItem>
<FormLabel>
{quickstart
? t('passwordCreateConfirm')
: t('passwordNewConfirm')
}
</FormLabel>
<FormControl>
<Input
type="password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
)}
{state === "mfa" && (
<Form {...mfaForm}>
<form
onSubmit={mfaForm.handleSubmit(onReset)}
className="space-y-4"
id="form"
>
<FormField
control={mfaForm.control}
name="code"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('pincodeAuth')}
</FormLabel>
<FormControl>
<div className="flex justify-center">
<InputOTP
maxLength={6}
{...field}
pattern={
REGEXP_ONLY_DIGITS_AND_CHARS
}
>
<InputOTPGroup>
<InputOTPSlot
index={0}
/>
<InputOTPSlot
index={1}
/>
<InputOTPSlot
index={2}
/>
<InputOTPSlot
index={3}
/>
<InputOTPSlot
index={4}
/>
<InputOTPSlot
index={5}
/>
</InputOTPGroup>
</InputOTP>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
)}
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{successMessage && (
<Alert variant="success">
<AlertDescription>
{successMessage}
</AlertDescription>
</Alert>
)}
<div className="space-y-4">
{(state === "reset" || state === "mfa") && (
<Button
type="submit"
form="form"
className="w-full"
disabled={isSubmitting}
>
{isSubmitting && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{state === "reset"
? (quickstart ? t('completeSetup') : t('passwordReset'))
: t('pincodeSubmit2')}
</Button>
)}
{state === "request" && (
<Button
type="submit"
form="form"
className="w-full"
disabled={isSubmitting}
>
{isSubmitting && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{quickstart
? t('accountSetupSubmit')
: t('passwordResetSubmit')
}
</Button>
)}
{state === "mfa" && (
<Button
type="button"
className="w-full"
variant="outline"
onClick={() => {
setState("reset");
mfaForm.reset();
}}
>
{t('passwordBack')}
</Button>
)}
{(state === "mfa" || state === "reset") && (
<Button
type="button"
className="w-full"
variant="outline"
onClick={() => {
setState("request");
form.reset();
}}
>
{t('backToEmail')}
</Button>
)}
</div>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,34 @@
"use client";
import { Button } from "@app/components/ui/button";
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from "@app/components/ui/card";
import Link from "next/link";
import { useTranslations } from "next-intl";
export default function ResourceAccessDenied() {
const t = useTranslations();
return (
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle className="text-center text-2xl font-bold">
{t('accessDenied')}
</CardTitle>
</CardHeader>
<CardContent>
{t('accessDeniedDescription')}
<div className="text-center mt-4">
<Button>
<Link href="/">{t('goHome')}</Link>
</Button>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,662 @@
"use client";
import { useState } from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import * as z from "zod";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@/components/ui/form";
import { LockIcon, Binary, Key, User, Send, AtSign } from "lucide-react";
import {
InputOTP,
InputOTPGroup,
InputOTPSlot
} from "@app/components/ui/input-otp";
import { useRouter } from "next/navigation";
import { Alert, AlertDescription } from "@app/components/ui/alert";
import { formatAxiosError } from "@app/lib/api";
import { AxiosResponse } from "axios";
import LoginForm, { LoginFormIDP } from "@app/components/LoginForm";
import {
AuthWithPasswordResponse,
AuthWithWhitelistResponse
} from "@server/routers/resource";
import ResourceAccessDenied from "@app/components/ResourceAccessDenied";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import Link from "next/link";
import Image from "next/image";
import { useSupporterStatusContext } from "@app/hooks/useSupporterStatusContext";
import { useTranslations } from "next-intl";
const pinSchema = z.object({
pin: z
.string()
.length(6, { message: "PIN must be exactly 6 digits" })
.regex(/^\d+$/, { message: "PIN must only contain numbers" })
});
const passwordSchema = z.object({
password: z.string().min(1, {
message: "Password must be at least 1 character long"
})
});
const requestOtpSchema = z.object({
email: z.string().email()
});
const submitOtpSchema = z.object({
email: z.string().email(),
otp: z.string().min(1, {
message: "OTP must be at least 1 character long"
})
});
type ResourceAuthPortalProps = {
methods: {
password: boolean;
pincode: boolean;
sso: boolean;
whitelist: boolean;
};
resource: {
name: string;
id: number;
};
redirect: string;
idps?: LoginFormIDP[];
};
export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
const router = useRouter();
const t = useTranslations();
const getNumMethods = () => {
let colLength = 0;
if (props.methods.pincode) colLength++;
if (props.methods.password) colLength++;
if (props.methods.sso) colLength++;
if (props.methods.whitelist) colLength++;
return colLength;
};
const [numMethods, setNumMethods] = useState(getNumMethods());
const [passwordError, setPasswordError] = useState<string | null>(null);
const [pincodeError, setPincodeError] = useState<string | null>(null);
const [whitelistError, setWhitelistError] = useState<string | null>(null);
const [accessDenied, setAccessDenied] = useState<boolean>(false);
const [loadingLogin, setLoadingLogin] = useState(false);
const [otpState, setOtpState] = useState<"idle" | "otp_sent">("idle");
const { env } = useEnvContext();
const api = createApiClient({ env });
const { supporterStatus } = useSupporterStatusContext();
function getDefaultSelectedMethod() {
if (props.methods.sso) {
return "sso";
}
if (props.methods.password) {
return "password";
}
if (props.methods.pincode) {
return "pin";
}
if (props.methods.whitelist) {
return "whitelist";
}
}
const [activeTab, setActiveTab] = useState(getDefaultSelectedMethod());
const pinForm = useForm<z.infer<typeof pinSchema>>({
resolver: zodResolver(pinSchema),
defaultValues: {
pin: ""
}
});
const passwordForm = useForm<z.infer<typeof passwordSchema>>({
resolver: zodResolver(passwordSchema),
defaultValues: {
password: ""
}
});
const requestOtpForm = useForm<z.infer<typeof requestOtpSchema>>({
resolver: zodResolver(requestOtpSchema),
defaultValues: {
email: ""
}
});
const submitOtpForm = useForm<z.infer<typeof submitOtpSchema>>({
resolver: zodResolver(submitOtpSchema),
defaultValues: {
email: "",
otp: ""
}
});
function appendRequestToken(url: string, token: string) {
const fullUrl = new URL(url);
fullUrl.searchParams.append(
env.server.resourceSessionRequestParam,
token
);
return fullUrl.toString();
}
const onWhitelistSubmit = (values: any) => {
setLoadingLogin(true);
api.post<AxiosResponse<AuthWithWhitelistResponse>>(
`/auth/resource/${props.resource.id}/whitelist`,
{ email: values.email, otp: values.otp }
)
.then((res) => {
setWhitelistError(null);
if (res.data.data.otpSent) {
setOtpState("otp_sent");
submitOtpForm.setValue("email", values.email);
toast({
title: t("otpEmailSent"),
description: t("otpEmailSentDescription")
});
return;
}
const session = res.data.data.session;
if (session) {
window.location.href = appendRequestToken(
props.redirect,
session
);
}
})
.catch((e) => {
console.error(e);
setWhitelistError(
formatAxiosError(e, t("otpEmailErrorAuthenticate"))
);
})
.then(() => setLoadingLogin(false));
};
const onPinSubmit = (values: z.infer<typeof pinSchema>) => {
setLoadingLogin(true);
api.post<AxiosResponse<AuthWithPasswordResponse>>(
`/auth/resource/${props.resource.id}/pincode`,
{ pincode: values.pin }
)
.then((res) => {
setPincodeError(null);
const session = res.data.data.session;
if (session) {
window.location.href = appendRequestToken(
props.redirect,
session
);
}
})
.catch((e) => {
console.error(e);
setPincodeError(
formatAxiosError(e, t("pincodeErrorAuthenticate"))
);
})
.then(() => setLoadingLogin(false));
};
const onPasswordSubmit = (values: z.infer<typeof passwordSchema>) => {
setLoadingLogin(true);
api.post<AxiosResponse<AuthWithPasswordResponse>>(
`/auth/resource/${props.resource.id}/password`,
{
password: values.password
}
)
.then((res) => {
setPasswordError(null);
const session = res.data.data.session;
if (session) {
window.location.href = appendRequestToken(
props.redirect,
session
);
}
})
.catch((e) => {
console.error(e);
setPasswordError(
formatAxiosError(e, t("passwordErrorAuthenticate"))
);
})
.finally(() => setLoadingLogin(false));
};
async function handleSSOAuth() {
let isAllowed = false;
try {
await api.get(`/resource/${props.resource.id}`);
isAllowed = true;
} catch (e) {
setAccessDenied(true);
}
if (isAllowed) {
// window.location.href = props.redirect;
router.refresh();
}
}
function getTitle() {
return t("authenticationRequired");
}
function getSubtitle(resourceName: string) {
return numMethods > 1
? t("authenticationMethodChoose", { name: props.resource.name })
: t("authenticationRequest", { name: props.resource.name });
}
return (
<div>
{!accessDenied ? (
<div>
<div className="text-center mb-2">
<span className="text-sm text-muted-foreground">
{t("poweredBy")}{" "}
<Link
href="https://github.com/fosrl/pangolin"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
Pangolin
</Link>
</span>
</div>
<Card>
<CardHeader>
<CardTitle>{getTitle()}</CardTitle>
<CardDescription>
{getSubtitle(props.resource.name)}
</CardDescription>
</CardHeader>
<CardContent>
<Tabs
value={activeTab}
onValueChange={setActiveTab}
orientation="horizontal"
>
{numMethods > 1 && (
<TabsList
className={`grid w-full ${
numMethods === 1
? "grid-cols-1"
: numMethods === 2
? "grid-cols-2"
: numMethods === 3
? "grid-cols-3"
: "grid-cols-4"
}`}
>
{props.methods.pincode && (
<TabsTrigger value="pin">
<Binary className="w-4 h-4 mr-1" />{" "}
PIN
</TabsTrigger>
)}
{props.methods.password && (
<TabsTrigger value="password">
<Key className="w-4 h-4 mr-1" />{" "}
{t("password")}
</TabsTrigger>
)}
{props.methods.sso && (
<TabsTrigger value="sso">
<User className="w-4 h-4 mr-1" />{" "}
{t("user")}
</TabsTrigger>
)}
{props.methods.whitelist && (
<TabsTrigger value="whitelist">
<AtSign className="w-4 h-4 mr-1" />{" "}
{t("email")}
</TabsTrigger>
)}
</TabsList>
)}
{props.methods.pincode && (
<TabsContent
value="pin"
className={`${numMethods <= 1 ? "mt-0" : ""}`}
>
<Form {...pinForm}>
<form
onSubmit={pinForm.handleSubmit(
onPinSubmit
)}
className="space-y-4"
>
<FormField
control={pinForm.control}
name="pin"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"pincodeInput"
)}
</FormLabel>
<FormControl>
<div className="flex justify-center">
<InputOTP
maxLength={
6
}
{...field}
>
<InputOTPGroup className="flex">
<InputOTPSlot
index={
0
}
obscured
/>
<InputOTPSlot
index={
1
}
obscured
/>
<InputOTPSlot
index={
2
}
obscured
/>
<InputOTPSlot
index={
3
}
obscured
/>
<InputOTPSlot
index={
4
}
obscured
/>
<InputOTPSlot
index={
5
}
obscured
/>
</InputOTPGroup>
</InputOTP>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{pincodeError && (
<Alert variant="destructive">
<AlertDescription>
{pincodeError}
</AlertDescription>
</Alert>
)}
<Button
type="submit"
className="w-full"
loading={loadingLogin}
disabled={loadingLogin}
>
<LockIcon className="w-4 h-4 mr-2" />
{t("pincodeSubmit")}
</Button>
</form>
</Form>
</TabsContent>
)}
{props.methods.password && (
<TabsContent
value="password"
className={`${numMethods <= 1 ? "mt-0" : ""}`}
>
<Form {...passwordForm}>
<form
onSubmit={passwordForm.handleSubmit(
onPasswordSubmit
)}
className="space-y-4"
>
<FormField
control={
passwordForm.control
}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("password")}
</FormLabel>
<FormControl>
<Input
type="password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{passwordError && (
<Alert variant="destructive">
<AlertDescription>
{passwordError}
</AlertDescription>
</Alert>
)}
<Button
type="submit"
className="w-full"
loading={loadingLogin}
disabled={loadingLogin}
>
<LockIcon className="w-4 h-4 mr-2" />
{t("passwordSubmit")}
</Button>
</form>
</Form>
</TabsContent>
)}
{props.methods.sso && (
<TabsContent
value="sso"
className={`${numMethods <= 1 ? "mt-0" : ""}`}
>
<LoginForm
idps={props.idps}
redirect={props.redirect}
onLogin={async () =>
await handleSSOAuth()
}
/>
</TabsContent>
)}
{props.methods.whitelist && (
<TabsContent
value="whitelist"
className={`${numMethods <= 1 ? "mt-0" : ""}`}
>
{otpState === "idle" && (
<Form {...requestOtpForm}>
<form
onSubmit={requestOtpForm.handleSubmit(
onWhitelistSubmit
)}
className="space-y-4"
>
<FormField
control={
requestOtpForm.control
}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("email")}
</FormLabel>
<FormControl>
<Input
type="email"
{...field}
/>
</FormControl>
<FormDescription>
{t(
"otpEmailDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{whitelistError && (
<Alert variant="destructive">
<AlertDescription>
{whitelistError}
</AlertDescription>
</Alert>
)}
<Button
type="submit"
className="w-full"
loading={loadingLogin}
disabled={loadingLogin}
>
<Send className="w-4 h-4 mr-2" />
{t("otpEmailSend")}
</Button>
</form>
</Form>
)}
{otpState === "otp_sent" && (
<Form {...submitOtpForm}>
<form
onSubmit={submitOtpForm.handleSubmit(
onWhitelistSubmit
)}
className="space-y-4"
>
<FormField
control={
submitOtpForm.control
}
name="otp"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"otpEmail"
)}
</FormLabel>
<FormControl>
<Input
type="password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{whitelistError && (
<Alert variant="destructive">
<AlertDescription>
{whitelistError}
</AlertDescription>
</Alert>
)}
<Button
type="submit"
className="w-full"
loading={loadingLogin}
disabled={loadingLogin}
>
<LockIcon className="w-4 h-4 mr-2" />
{t("otpEmailSubmit")}
</Button>
<Button
type="button"
className="w-full"
variant={"outline"}
onClick={() => {
setOtpState("idle");
submitOtpForm.reset();
}}
>
{t("backToEmail")}
</Button>
</form>
</Form>
)}
</TabsContent>
)}
</Tabs>
</CardContent>
</Card>
{supporterStatus?.visible && (
<div className="text-center mt-2">
<span className="text-sm text-muted-foreground opacity-50">
{t("noSupportKey")}
</span>
</div>
)}
</div>
) : (
<ResourceAccessDenied />
)}
</div>
);
}

View File

@@ -0,0 +1,135 @@
"use client";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { InfoIcon, ShieldCheck, ShieldOff } from "lucide-react";
import { useResourceContext } from "@app/hooks/useResourceContext";
import CopyToClipboard from "@app/components/CopyToClipboard";
import {
InfoSection,
InfoSectionContent,
InfoSections,
InfoSectionTitle
} from "@app/components/InfoSection";
import { useTranslations } from "next-intl";
import { build } from "@server/build";
import { toUnicode } from 'punycode';
type ResourceInfoBoxType = {};
export default function ResourceInfoBox({ }: ResourceInfoBoxType) {
const { resource, authInfo } = useResourceContext();
const t = useTranslations();
const fullUrl = `${resource.ssl ? "https" : "http"}://${toUnicode(resource.fullDomain || "")}`;
return (
<Alert>
<AlertDescription>
<InfoSections cols={3}>
{resource.http ? (
<>
<InfoSection>
<InfoSectionTitle>
{t("authentication")}
</InfoSectionTitle>
<InfoSectionContent>
{authInfo.password ||
authInfo.pincode ||
authInfo.sso ||
authInfo.whitelist ? (
<div className="flex items-start space-x-2 text-green-500">
<ShieldCheck className="w-4 h-4 mt-0.5" />
<span>{t("protected")}</span>
</div>
) : (
<div className="flex items-center space-x-2 text-yellow-500">
<ShieldOff className="w-4 h-4" />
<span>{t("notProtected")}</span>
</div>
)}
</InfoSectionContent>
</InfoSection>
<InfoSection>
<InfoSectionTitle>URL</InfoSectionTitle>
<InfoSectionContent>
<CopyToClipboard
text={fullUrl}
isLink={true}
/>
</InfoSectionContent>
</InfoSection>
{/* {isEnabled && (
<InfoSection>
<InfoSectionTitle>Socket</InfoSectionTitle>
<InfoSectionContent>
{isAvailable ? (
<span className="text-green-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span>Online</span>
</span>
) : (
<span className="text-neutral-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-gray-500 rounded-full"></div>
<span>Offline</span>
</span>
)}
</InfoSectionContent>
</InfoSection>
)} */}
</>
) : (
<>
<InfoSection>
<InfoSectionTitle>
{t("protocol")}
</InfoSectionTitle>
<InfoSectionContent>
<span>
{resource.protocol.toUpperCase()}
</span>
</InfoSectionContent>
</InfoSection>
<InfoSection>
<InfoSectionTitle>{t("port")}</InfoSectionTitle>
<InfoSectionContent>
<CopyToClipboard
text={resource.proxyPort!.toString()}
isLink={false}
/>
</InfoSectionContent>
</InfoSection>
{/* {build == "oss" && (
<InfoSection>
<InfoSectionTitle>
{t("externalProxyEnabled")}
</InfoSectionTitle>
<InfoSectionContent>
<span>
{resource.enableProxy
? t("enabled")
: t("disabled")}
</span>
</InfoSectionContent>
</InfoSection>
)} */}
</>
)}
<InfoSection>
<InfoSectionTitle>{t("visibility")}</InfoSectionTitle>
<InfoSectionContent>
<span>
{resource.enabled
? t("enabled")
: t("disabled")}
</span>
</InfoSectionContent>
</InfoSection>
</InfoSections>
</AlertDescription>
</Alert>
);
}

View File

@@ -0,0 +1,33 @@
import { Button } from "@app/components/ui/button";
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from "@app/components/ui/card";
import Link from "next/link";
import { getTranslations } from "next-intl/server";
export default async function ResourceNotFound() {
const t = await getTranslations();
return (
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle className="text-center text-2xl font-bold">
{t('resourceNotFound')}
</CardTitle>
</CardHeader>
<CardContent>
{t('resourceNotFoundDescription')}
<div className="text-center mt-4">
<Button>
<Link href="/">{t('goHome')}</Link>
</Button>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,954 @@
"use client";
import {
ColumnDef,
flexRender,
getCoreRowModel,
useReactTable,
getPaginationRowModel,
SortingState,
getSortedRowModel,
ColumnFiltersState,
getFilteredRowModel
} from "@tanstack/react-table";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu";
import { Button } from "@app/components/ui/button";
import {
ArrowRight,
ArrowUpDown,
MoreHorizontal,
ArrowUpRight,
ShieldOff,
ShieldCheck
} from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState, useEffect } from "react";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { formatAxiosError } from "@app/lib/api";
import { toast } from "@app/hooks/useToast";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import CopyToClipboard from "@app/components/CopyToClipboard";
import { Switch } from "@app/components/ui/switch";
import { AxiosResponse } from "axios";
import { UpdateResourceResponse } from "@server/routers/resource";
import { ListSitesResponse } from "@server/routers/site";
import { useTranslations } from "next-intl";
import { InfoPopup } from "@app/components/ui/info-popup";
import { Input } from "@app/components/ui/input";
import { DataTablePagination } from "@app/components/DataTablePagination";
import { Plus, Search } from "lucide-react";
import { Card, CardContent, CardHeader } from "@app/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from "@app/components/ui/table";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger
} from "@app/components/ui/tabs";
import { useSearchParams } from "next/navigation";
import EditInternalResourceDialog from "@app/components/EditInternalResourceDialog";
import CreateInternalResourceDialog from "@app/components/CreateInternalResourceDialog";
import { Alert, AlertDescription } from "@app/components/ui/alert";
export type ResourceRow = {
id: number;
name: string;
orgId: string;
domain: string;
authState: string;
http: boolean;
protocol: string;
proxyPort: number | null;
enabled: boolean;
domainId?: string;
};
export type InternalResourceRow = {
id: number;
name: string;
orgId: string;
siteName: string;
protocol: string;
proxyPort: number | null;
siteId: number;
siteNiceId: string;
destinationIp: string;
destinationPort: number;
};
type Site = ListSitesResponse["sites"][0];
type ResourcesTableProps = {
resources: ResourceRow[];
internalResources: InternalResourceRow[];
orgId: string;
defaultView?: "proxy" | "internal";
};
const STORAGE_KEYS = {
PAGE_SIZE: 'datatable-page-size',
getTablePageSize: (tableId?: string) =>
tableId ? `datatable-${tableId}-page-size` : STORAGE_KEYS.PAGE_SIZE
};
const getStoredPageSize = (tableId?: string, defaultSize = 20): number => {
if (typeof window === 'undefined') return defaultSize;
try {
const key = STORAGE_KEYS.getTablePageSize(tableId);
const stored = localStorage.getItem(key);
if (stored) {
const parsed = parseInt(stored, 10);
if (parsed > 0 && parsed <= 1000) {
return parsed;
}
}
} catch (error) {
console.warn('Failed to read page size from localStorage:', error);
}
return defaultSize;
};
const setStoredPageSize = (pageSize: number, tableId?: string): void => {
if (typeof window === 'undefined') return;
try {
const key = STORAGE_KEYS.getTablePageSize(tableId);
localStorage.setItem(key, pageSize.toString());
} catch (error) {
console.warn('Failed to save page size to localStorage:', error);
}
};
export default function ResourcesTable({
resources,
internalResources,
orgId,
defaultView = "proxy"
}: ResourcesTableProps) {
const router = useRouter();
const searchParams = useSearchParams();
const t = useTranslations();
const { env } = useEnvContext();
const api = createApiClient({ env });
const [proxyPageSize, setProxyPageSize] = useState<number>(() =>
getStoredPageSize('proxy-resources', 20)
);
const [internalPageSize, setInternalPageSize] = useState<number>(() =>
getStoredPageSize('internal-resources', 20)
);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selectedResource, setSelectedResource] =
useState<ResourceRow | null>();
const [selectedInternalResource, setSelectedInternalResource] =
useState<InternalResourceRow | null>();
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const [editingResource, setEditingResource] =
useState<InternalResourceRow | null>();
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [sites, setSites] = useState<Site[]>([]);
const [proxySorting, setProxySorting] = useState<SortingState>([]);
const [proxyColumnFilters, setProxyColumnFilters] =
useState<ColumnFiltersState>([]);
const [proxyGlobalFilter, setProxyGlobalFilter] = useState<any>([]);
const [internalSorting, setInternalSorting] = useState<SortingState>([]);
const [internalColumnFilters, setInternalColumnFilters] =
useState<ColumnFiltersState>([]);
const [internalGlobalFilter, setInternalGlobalFilter] = useState<any>([]);
const currentView = searchParams.get("view") || defaultView;
useEffect(() => {
const fetchSites = async () => {
try {
const res = await api.get<AxiosResponse<ListSitesResponse>>(
`/org/${orgId}/sites`
);
setSites(res.data.data.sites);
} catch (error) {
console.error("Failed to fetch sites:", error);
}
};
if (orgId) {
fetchSites();
}
}, [orgId]);
const handleTabChange = (value: string) => {
const params = new URLSearchParams(searchParams);
if (value === "internal") {
params.set("view", "internal");
} else {
params.delete("view");
}
const newUrl = `${window.location.pathname}${params.toString() ? "?" + params.toString() : ""}`;
router.replace(newUrl, { scroll: false });
};
const getSearchInput = () => {
if (currentView === "internal") {
return (
<div className="relative w-full sm:max-w-sm">
<Input
placeholder={t("resourcesSearch")}
value={internalGlobalFilter ?? ""}
onChange={(e) =>
internalTable.setGlobalFilter(
String(e.target.value)
)
}
className="w-full pl-8"
/>
<Search className="h-4 w-4 absolute left-2 top-1/2 transform -translate-y-1/2 text-muted-foreground" />
</div>
);
}
return (
<div className="relative w-full sm:max-w-sm">
<Input
placeholder={t("resourcesSearch")}
value={proxyGlobalFilter ?? ""}
onChange={(e) =>
proxyTable.setGlobalFilter(String(e.target.value))
}
className="w-full pl-8"
/>
<Search className="h-4 w-4 absolute left-2 top-1/2 transform -translate-y-1/2 text-muted-foreground" />
</div>
);
};
const getActionButton = () => {
if (currentView === "internal") {
return (
<Button onClick={() => setIsCreateDialogOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
{t("resourceAdd")}
</Button>
);
}
return (
<Button
onClick={() =>
router.push(`/${orgId}/settings/resources/create`)
}
>
<Plus className="mr-2 h-4 w-4" />
{t("resourceAdd")}
</Button>
);
};
const deleteResource = (resourceId: number) => {
api.delete(`/resource/${resourceId}`)
.catch((e) => {
console.error(t("resourceErrorDelte"), e);
toast({
variant: "destructive",
title: t("resourceErrorDelte"),
description: formatAxiosError(e, t("resourceErrorDelte"))
});
})
.then(() => {
router.refresh();
setIsDeleteModalOpen(false);
});
};
const deleteInternalResource = async (
resourceId: number,
siteId: number
) => {
try {
await api.delete(
`/org/${orgId}/site/${siteId}/resource/${resourceId}`
);
router.refresh();
setIsDeleteModalOpen(false);
} catch (e) {
console.error(t("resourceErrorDelete"), e);
toast({
variant: "destructive",
title: t("resourceErrorDelte"),
description: formatAxiosError(e, t("v"))
});
}
};
async function toggleResourceEnabled(val: boolean, resourceId: number) {
const res = await api
.post<AxiosResponse<UpdateResourceResponse>>(
`resource/${resourceId}`,
{
enabled: val
}
)
.catch((e) => {
toast({
variant: "destructive",
title: t("resourcesErrorUpdate"),
description: formatAxiosError(
e,
t("resourcesErrorUpdateDescription")
)
});
});
}
const proxyColumns: ColumnDef<ResourceRow>[] = [
{
accessorKey: "name",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("name")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "protocol",
header: t("protocol"),
cell: ({ row }) => {
const resourceRow = row.original;
return <span>{resourceRow.protocol.toUpperCase()}</span>;
}
},
{
accessorKey: "domain",
header: t("access"),
cell: ({ row }) => {
const resourceRow = row.original;
return (
<div className="flex items-center space-x-2">
{!resourceRow.http ? (
<CopyToClipboard
text={resourceRow.proxyPort!.toString()}
isLink={false}
/>
) : !resourceRow.domainId ? (
<InfoPopup
info={t("domainNotFoundDescription")}
text={t("domainNotFound")}
/>
) : (
<CopyToClipboard
text={resourceRow.domain}
isLink={true}
/>
)}
</div>
);
}
},
{
accessorKey: "authState",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("authentication")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const resourceRow = row.original;
return (
<div>
{resourceRow.authState === "protected" ? (
<span className="text-green-500 flex items-center space-x-2">
<ShieldCheck className="w-4 h-4" />
<span>{t("protected")}</span>
</span>
) : resourceRow.authState === "not_protected" ? (
<span className="text-yellow-500 flex items-center space-x-2">
<ShieldOff className="w-4 h-4" />
<span>{t("notProtected")}</span>
</span>
) : (
<span>-</span>
)}
</div>
);
}
},
{
accessorKey: "enabled",
header: t("enabled"),
cell: ({ row }) => (
<Switch
defaultChecked={
row.original.http
? !!row.original.domainId && row.original.enabled
: row.original.enabled
}
disabled={
row.original.http ? !row.original.domainId : false
}
onCheckedChange={(val) =>
toggleResourceEnabled(val, row.original.id)
}
/>
)
},
{
id: "actions",
cell: ({ row }) => {
const resourceRow = row.original;
return (
<div className="flex items-center justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">
{t("openMenu")}
</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<Link
className="block w-full"
href={`/${resourceRow.orgId}/settings/resources/${resourceRow.id}`}
>
<DropdownMenuItem>
{t("viewSettings")}
</DropdownMenuItem>
</Link>
<DropdownMenuItem
onClick={() => {
setSelectedResource(resourceRow);
setIsDeleteModalOpen(true);
}}
>
<span className="text-red-500">
{t("delete")}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Link
href={`/${resourceRow.orgId}/settings/resources/${resourceRow.id}`}
>
<Button
variant={"secondary"}
className="ml-2"
size="sm"
>
{t("edit")}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</Link>
</div>
);
}
}
];
const internalColumns: ColumnDef<InternalResourceRow>[] = [
{
accessorKey: "name",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("name")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "siteName",
header: t("siteName"),
cell: ({ row }) => {
const resourceRow = row.original;
return (
<Link
href={`/${resourceRow.orgId}/settings/sites/${resourceRow.siteNiceId}`}
>
<Button variant="outline" size="sm">
{resourceRow.siteName}
<ArrowUpRight className="ml-2 h-4 w-4" />
</Button>
</Link>
);
}
},
{
accessorKey: "protocol",
header: t("protocol"),
cell: ({ row }) => {
const resourceRow = row.original;
return <span>{resourceRow.protocol.toUpperCase()}</span>;
}
},
{
accessorKey: "proxyPort",
header: t("proxyPort"),
cell: ({ row }) => {
const resourceRow = row.original;
return (
<CopyToClipboard
text={resourceRow.proxyPort!.toString()}
isLink={false}
/>
);
}
},
{
accessorKey: "destination",
header: t("resourcesTableDestination"),
cell: ({ row }) => {
const resourceRow = row.original;
const destination = `${resourceRow.destinationIp}:${resourceRow.destinationPort}`;
return <CopyToClipboard text={destination} isLink={false} />;
}
},
{
id: "actions",
cell: ({ row }) => {
const resourceRow = row.original;
return (
<div className="flex items-center justify-end gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">
{t("openMenu")}
</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
setSelectedInternalResource(
resourceRow
);
setIsDeleteModalOpen(true);
}}
>
<span className="text-red-500">
{t("delete")}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant={"secondary"}
size="sm"
onClick={() => {
setEditingResource(resourceRow);
setIsEditDialogOpen(true);
}}
>
{t("edit")}
</Button>
</div>
);
}
}
];
const proxyTable = useReactTable({
data: resources,
columns: proxyColumns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onSortingChange: setProxySorting,
getSortedRowModel: getSortedRowModel(),
onColumnFiltersChange: setProxyColumnFilters,
getFilteredRowModel: getFilteredRowModel(),
onGlobalFilterChange: setProxyGlobalFilter,
initialState: {
pagination: {
pageSize: proxyPageSize,
pageIndex: 0
}
},
state: {
sorting: proxySorting,
columnFilters: proxyColumnFilters,
globalFilter: proxyGlobalFilter
}
});
const internalTable = useReactTable({
data: internalResources,
columns: internalColumns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onSortingChange: setInternalSorting,
getSortedRowModel: getSortedRowModel(),
onColumnFiltersChange: setInternalColumnFilters,
getFilteredRowModel: getFilteredRowModel(),
onGlobalFilterChange: setInternalGlobalFilter,
initialState: {
pagination: {
pageSize: internalPageSize,
pageIndex: 0
}
},
state: {
sorting: internalSorting,
columnFilters: internalColumnFilters,
globalFilter: internalGlobalFilter
}
});
const handleProxyPageSizeChange = (newPageSize: number) => {
setProxyPageSize(newPageSize);
setStoredPageSize(newPageSize, 'proxy-resources');
};
const handleInternalPageSizeChange = (newPageSize: number) => {
setInternalPageSize(newPageSize);
setStoredPageSize(newPageSize, 'internal-resources');
};
return (
<>
{selectedResource && (
<ConfirmDeleteDialog
open={isDeleteModalOpen}
setOpen={(val) => {
setIsDeleteModalOpen(val);
setSelectedResource(null);
}}
dialog={
<div>
<p className="mb-2">
{t("resourceQuestionRemove", {
selectedResource:
selectedResource?.name ||
selectedResource?.id
})}
</p>
<p className="mb-2">{t("resourceMessageRemove")}</p>
<p>{t("resourceMessageConfirm")}</p>
</div>
}
buttonText={t("resourceDeleteConfirm")}
onConfirm={async () => deleteResource(selectedResource!.id)}
string={selectedResource.name}
title={t("resourceDelete")}
/>
)}
{selectedInternalResource && (
<ConfirmDeleteDialog
open={isDeleteModalOpen}
setOpen={(val) => {
setIsDeleteModalOpen(val);
setSelectedInternalResource(null);
}}
dialog={
<div>
<p className="mb-2">
{t("resourceQuestionRemove", {
selectedResource:
selectedInternalResource?.name ||
selectedInternalResource?.id
})}
</p>
<p className="mb-2">{t("resourceMessageRemove")}</p>
<p>{t("resourceMessageConfirm")}</p>
</div>
}
buttonText={t("resourceDeleteConfirm")}
onConfirm={async () =>
deleteInternalResource(
selectedInternalResource!.id,
selectedInternalResource!.siteId
)
}
string={selectedInternalResource.name}
title={t("resourceDelete")}
/>
)}
<div className="container mx-auto max-w-12xl">
<Card>
<Tabs
defaultValue={defaultView}
className="w-full"
onValueChange={handleTabChange}
>
<CardHeader className="flex flex-col space-y-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0 pb-0">
<div className="flex flex-row space-y-3 w-full sm:mr-2 gap-2">
{getSearchInput()}
{env.flags.enableClients && (
<TabsList className="grid grid-cols-2">
<TabsTrigger value="proxy">
{t("resourcesTableProxyResources")}
</TabsTrigger>
<TabsTrigger value="internal">
{t("resourcesTableClientResources")}
</TabsTrigger>
</TabsList>
)}
</div>
<div className="flex items-center gap-2 sm:justify-end">
{getActionButton()}
</div>
</CardHeader>
<CardContent>
<TabsContent value="proxy">
<Table>
<TableHeader>
{proxyTable
.getHeaderGroups()
.map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map(
(header) => (
<TableHead
key={header.id}
>
{header.isPlaceholder
? null
: flexRender(
header
.column
.columnDef
.header,
header.getContext()
)}
</TableHead>
)
)}
</TableRow>
))}
</TableHeader>
<TableBody>
{proxyTable.getRowModel().rows
?.length ? (
proxyTable
.getRowModel()
.rows.map((row) => (
<TableRow
key={row.id}
data-state={
row.getIsSelected() &&
"selected"
}
>
{row
.getVisibleCells()
.map((cell) => (
<TableCell
key={
cell.id
}
>
{flexRender(
cell
.column
.columnDef
.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={
proxyColumns.length
}
className="h-24 text-center"
>
{t(
"resourcesTableNoProxyResourcesFound"
)}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
<div className="mt-4">
<DataTablePagination
table={proxyTable}
onPageSizeChange={handleProxyPageSizeChange}
/>
</div>
</TabsContent>
<TabsContent value="internal">
<div className="mb-4">
<Alert variant="neutral">
<AlertDescription>
{t(
"resourcesTableTheseResourcesForUseWith"
)}{" "}
<Link
href={`/${orgId}/settings/clients`}
className="font-medium underline hover:opacity-80 inline-flex items-center"
>
{t("resourcesTableClients")}
<ArrowUpRight className="ml-1 h-3 w-3" />
</Link>{" "}
{t(
"resourcesTableAndOnlyAccessibleInternally"
)}
</AlertDescription>
</Alert>
</div>
<Table>
<TableHeader>
{internalTable
.getHeaderGroups()
.map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map(
(header) => (
<TableHead
key={header.id}
>
{header.isPlaceholder
? null
: flexRender(
header
.column
.columnDef
.header,
header.getContext()
)}
</TableHead>
)
)}
</TableRow>
))}
</TableHeader>
<TableBody>
{internalTable.getRowModel().rows
?.length ? (
internalTable
.getRowModel()
.rows.map((row) => (
<TableRow
key={row.id}
data-state={
row.getIsSelected() &&
"selected"
}
>
{row
.getVisibleCells()
.map((cell) => (
<TableCell
key={
cell.id
}
>
{flexRender(
cell
.column
.columnDef
.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={
internalColumns.length
}
className="h-24 text-center"
>
{t(
"resourcesTableNoInternalResourcesFound"
)}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
<div className="mt-4">
<DataTablePagination
table={internalTable}
onPageSizeChange={handleInternalPageSizeChange}
/>
</div>
</TabsContent>
</CardContent>
</Tabs>
</Card>
</div>
{editingResource && (
<EditInternalResourceDialog
open={isEditDialogOpen}
setOpen={setIsEditDialogOpen}
resource={editingResource}
orgId={orgId}
onSuccess={() => {
router.refresh();
setEditingResource(null);
}}
/>
)}
<CreateInternalResourceDialog
open={isCreateDialogOpen}
setOpen={setIsCreateDialogOpen}
orgId={orgId}
sites={sites}
onSuccess={() => {
router.refresh();
}}
/>
</>
);
}

View File

@@ -0,0 +1,35 @@
"use client";
import {
ColumnDef,
} from "@tanstack/react-table";
import { DataTable } from "@app/components/ui/data-table";
import { useTranslations } from 'next-intl';
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
createRole?: () => void;
}
export function RolesDataTable<TData, TValue>({
columns,
data,
createRole
}: DataTableProps<TData, TValue>) {
const t = useTranslations();
return (
<DataTable
columns={columns}
data={data}
persistPageSize="roles-table"
title={t('roles')}
searchPlaceholder={t('accessRolesSearch')}
searchColumn="name"
onAdd={createRole}
addButtonText={t('accessRolesAdd')}
/>
);
}

View File

@@ -0,0 +1,122 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu";
import { Button } from "@app/components/ui/button";
import { ArrowUpDown, Crown, MoreHorizontal } from "lucide-react";
import { useState } from "react";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { useOrgContext } from "@app/hooks/useOrgContext";
import { toast } from "@app/hooks/useToast";
import { RolesDataTable } from "@app/components/RolesDataTable";
import { Role } from "@server/db";
import CreateRoleForm from "@app/components/CreateRoleForm";
import DeleteRoleForm from "@app/components/DeleteRoleForm";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useTranslations } from "next-intl";
export type RoleRow = Role;
type RolesTableProps = {
roles: RoleRow[];
};
export default function UsersTable({ roles: r }: RolesTableProps) {
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [roles, setRoles] = useState<RoleRow[]>(r);
const [roleToRemove, setUserToRemove] = useState<RoleRow | null>(null);
const api = createApiClient(useEnvContext());
const { org } = useOrgContext();
const t = useTranslations();
const columns: ColumnDef<RoleRow>[] = [
{
accessorKey: "name",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("name")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "description",
header: t("description")
},
{
id: "actions",
cell: ({ row }) => {
const roleRow = row.original;
return (
<div className="flex items-center justify-end">
<Button
variant={"secondary"}
size="sm"
disabled={roleRow.isAdmin || false}
onClick={() => {
setIsDeleteModalOpen(true);
setUserToRemove(roleRow);
}}
>
{t("accessRoleDelete")}
</Button>
</div>
);
}
}
];
return (
<>
<CreateRoleForm
open={isCreateModalOpen}
setOpen={setIsCreateModalOpen}
afterCreate={async (role) => {
setRoles((prev) => [...prev, role]);
}}
/>
{roleToRemove && (
<DeleteRoleForm
open={isDeleteModalOpen}
setOpen={setIsDeleteModalOpen}
roleToDelete={roleToRemove}
afterDelete={() => {
setRoles((prev) =>
prev.filter((r) => r.roleId !== roleToRemove.roleId)
);
setUserToRemove(null);
}}
/>
)}
<RolesDataTable
columns={columns}
data={roles}
createRole={() => {
setIsCreateModalOpen(true);
}}
/>
</>
);
}

View File

@@ -0,0 +1,167 @@
"use client";
import { Button } from "@app/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
import { toast } from "@app/hooks/useToast";
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import {
Credenza,
CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "@app/components/Credenza";
import { formatAxiosError } from "@app/lib/api";
import { AxiosResponse } from "axios";
import { Resource } from "@server/db";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useTranslations } from "next-intl";
const setPasswordFormSchema = z.object({
password: z.string().min(4).max(100)
});
type SetPasswordFormValues = z.infer<typeof setPasswordFormSchema>;
const defaultValues: Partial<SetPasswordFormValues> = {
password: ""
};
type SetPasswordFormProps = {
open: boolean;
setOpen: (open: boolean) => void;
resourceId: number;
onSetPassword?: () => void;
};
export default function SetResourcePasswordForm({
open,
setOpen,
resourceId,
onSetPassword
}: SetPasswordFormProps) {
const api = createApiClient(useEnvContext());
const t = useTranslations();
const [loading, setLoading] = useState(false);
const form = useForm<SetPasswordFormValues>({
resolver: zodResolver(setPasswordFormSchema),
defaultValues
});
useEffect(() => {
if (!open) {
return;
}
form.reset();
}, [open]);
async function onSubmit(data: SetPasswordFormValues) {
setLoading(true);
api.post<AxiosResponse<Resource>>(`/resource/${resourceId}/password`, {
password: data.password
})
.catch((e) => {
toast({
variant: "destructive",
title: t('resourceErrorPasswordSetup'),
description: formatAxiosError(
e,
t('resourceErrorPasswordSetupDescription')
)
});
})
.then(() => {
toast({
title: t('resourcePasswordSetup'),
description: t('resourcePasswordSetupDescription')
});
if (onSetPassword) {
onSetPassword();
}
})
.finally(() => setLoading(false));
}
return (
<>
<Credenza
open={open}
onOpenChange={(val) => {
setOpen(val);
setLoading(false);
form.reset();
}}
>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>{t('resourcePasswordSetupTitle')}</CredenzaTitle>
<CredenzaDescription>
{t('resourcePasswordSetupTitleDescription')}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
id="set-password-form"
>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>{t('password')}</FormLabel>
<FormControl>
<Input
autoComplete="off"
type="password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">{t('close')}</Button>
</CredenzaClose>
<Button
type="submit"
form="set-password-form"
loading={loading}
disabled={loading}
>
{t('resourcePasswordSubmit')}
</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
</>
);
}

View File

@@ -0,0 +1,202 @@
"use client";
import { Button } from "@app/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
import { toast } from "@app/hooks/useToast";
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import {
Credenza,
CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "@app/components/Credenza";
import { formatAxiosError } from "@app/lib/api";
import { AxiosResponse } from "axios";
import { Resource } from "@server/db";
import {
InputOTP,
InputOTPGroup,
InputOTPSlot
} from "@app/components/ui/input-otp";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useTranslations } from "next-intl";
const setPincodeFormSchema = z.object({
pincode: z.string().length(6)
});
type SetPincodeFormValues = z.infer<typeof setPincodeFormSchema>;
const defaultValues: Partial<SetPincodeFormValues> = {
pincode: ""
};
type SetPincodeFormProps = {
open: boolean;
setOpen: (open: boolean) => void;
resourceId: number;
onSetPincode?: () => void;
};
export default function SetResourcePincodeForm({
open,
setOpen,
resourceId,
onSetPincode
}: SetPincodeFormProps) {
const [loading, setLoading] = useState(false);
const api = createApiClient(useEnvContext());
const form = useForm<SetPincodeFormValues>({
resolver: zodResolver(setPincodeFormSchema),
defaultValues
});
const t = useTranslations();
useEffect(() => {
if (!open) {
return;
}
form.reset();
}, [open]);
async function onSubmit(data: SetPincodeFormValues) {
setLoading(true);
api.post<AxiosResponse<Resource>>(`/resource/${resourceId}/pincode`, {
pincode: data.pincode
})
.catch((e) => {
toast({
variant: "destructive",
title: t('resourceErrorPincodeSetup'),
description: formatAxiosError(
e,
t('resourceErrorPincodeSetupDescription')
)
});
})
.then(() => {
toast({
title: t('resourcePincodeSetup'),
description: t('resourcePincodeSetupDescription')
});
if (onSetPincode) {
onSetPincode();
}
})
.finally(() => setLoading(false));
}
return (
<>
<Credenza
open={open}
onOpenChange={(val) => {
setOpen(val);
setLoading(false);
form.reset();
}}
>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>{t('resourcePincodeSetupTitle')}</CredenzaTitle>
<CredenzaDescription>
{t('resourcePincodeSetupTitleDescription')}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
id="set-pincode-form"
>
<FormField
control={form.control}
name="pincode"
render={({ field }) => (
<FormItem>
<FormLabel>{t('resourcePincode')}</FormLabel>
<FormControl>
<div className="flex justify-center">
<InputOTP
autoComplete="false"
maxLength={6}
{...field}
>
<InputOTPGroup className="flex">
<InputOTPSlot
index={0}
obscured
/>
<InputOTPSlot
index={1}
obscured
/>
<InputOTPSlot
index={2}
obscured
/>
<InputOTPSlot
index={3}
obscured
/>
<InputOTPSlot
index={4}
obscured
/>
<InputOTPSlot
index={5}
obscured
/>
</InputOTPGroup>
</InputOTP>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">{t('close')}</Button>
</CredenzaClose>
<Button
type="submit"
form="set-pincode-form"
loading={loading}
disabled={loading}
>
{t('resourcePincodeSubmit')}
</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
</>
);
}

View File

@@ -0,0 +1,35 @@
"use client";
import {
ColumnDef,
} from "@tanstack/react-table";
import { DataTable } from "@app/components/ui/data-table";
import { useTranslations } from "next-intl";
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
createShareLink?: () => void;
}
export function ShareLinksDataTable<TData, TValue>({
columns,
data,
createShareLink
}: DataTableProps<TData, TValue>) {
const t = useTranslations();
return (
<DataTable
columns={columns}
data={data}
persistPageSize="shareLinks-table"
title={t('shareLinks')}
searchPlaceholder={t('shareSearch')}
searchColumn="name"
onAdd={createShareLink}
addButtonText={t('shareCreate')}
/>
);
}

View File

@@ -0,0 +1,70 @@
"use client";
import React, { useState, useEffect } from "react";
import { Link, X, Clock, Share, ArrowRight, Lock } from "lucide-react"; // Replace with actual imports
import { Card, CardContent } from "@app/components/ui/card";
import { Button } from "@app/components/ui/button";
import { useTranslations } from "next-intl";
export const ShareableLinksSplash = () => {
const [isDismissed, setIsDismissed] = useState(false);
const key = "share-links-splash-dismissed";
useEffect(() => {
const dismissed = localStorage.getItem(key);
if (dismissed === "true") {
setIsDismissed(true);
}
}, []);
const handleDismiss = () => {
setIsDismissed(true);
localStorage.setItem(key, "true");
};
const t = useTranslations();
if (isDismissed) {
return null;
}
return (
<Card className="w-full mx-auto overflow-hidden mb-8 hidden md:block relative">
<button
onClick={handleDismiss}
className="absolute top-2 right-2 p-2"
aria-label={t('dismiss')}
>
<X className="w-5 h-5" />
</button>
<CardContent className="grid gap-6 p-6">
<div className="space-y-4">
<h3 className="text-xl font-semibold flex items-center gap-2">
<Link className="text-blue-500" />
{t('share')}
</h3>
<p className="text-sm">
{t('shareDescription2')}
</p>
<ul className="text-sm text-muted-foreground space-y-2">
<li className="flex items-center gap-2">
<Share className="text-green-500 w-4 h-4" />
{t('shareEasyCreate')}
</li>
<li className="flex items-center gap-2">
<Clock className="text-yellow-500 w-4 h-4" />
{t('shareConfigurableExpirationDuration')}
</li>
<li className="flex items-center gap-2">
<Lock className="text-red-500 w-4 h-4" />
{t('shareSecureAndRevocable')}
</li>
</ul>
</div>
</CardContent>
</Card>
);
};
export default ShareableLinksSplash;

View File

@@ -0,0 +1,298 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { ShareLinksDataTable } from "@app/components/ShareLinksDataTable";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu";
import { Button } from "@app/components/ui/button";
import {
Copy,
ArrowRight,
ArrowUpDown,
MoreHorizontal,
Check,
ArrowUpRight,
ShieldOff,
ShieldCheck
} from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
// import CreateResourceForm from "./CreateResourceForm";
import { useState } from "react";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { formatAxiosError } from "@app/lib/api";
import { toast } from "@app/hooks/useToast";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { ArrayElement } from "@server/types/ArrayElement";
import { ListAccessTokensResponse } from "@server/routers/accessToken";
import moment from "moment";
import CreateShareLinkForm from "@app/components/CreateShareLinkForm";
import { constructShareLink } from "@app/lib/shareLinks";
import { useTranslations } from "next-intl";
export type ShareLinkRow = {
accessTokenId: string;
resourceId: number;
resourceName: string;
title: string | null;
createdAt: number;
expiresAt: number | null;
};
type ShareLinksTableProps = {
shareLinks: ShareLinkRow[];
orgId: string;
};
export default function ShareLinksTable({
shareLinks,
orgId
}: ShareLinksTableProps) {
const router = useRouter();
const t = useTranslations();
const api = createApiClient(useEnvContext());
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [rows, setRows] = useState<ShareLinkRow[]>(shareLinks);
function formatLink(link: string) {
return link.substring(0, 20) + "..." + link.substring(link.length - 20);
}
async function deleteSharelink(id: string) {
await api.delete(`/access-token/${id}`).catch((e) => {
toast({
title: t("shareErrorDelete"),
description: formatAxiosError(e, t("shareErrorDeleteMessage"))
});
});
const newRows = rows.filter((r) => r.accessTokenId !== id);
setRows(newRows);
toast({
title: t("shareDeleted"),
description: t("shareDeletedDescription")
});
}
const columns: ColumnDef<ShareLinkRow>[] = [
{
accessorKey: "resourceName",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("resource")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const r = row.original;
return (
<Link href={`/${orgId}/settings/resources/${r.resourceId}`}>
<Button variant="outline" size="sm">
{r.resourceName}
<ArrowUpRight className="ml-2 h-4 w-4" />
</Button>
</Link>
);
}
},
{
accessorKey: "title",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("title")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
// {
// accessorKey: "domain",
// header: "Link",
// cell: ({ row }) => {
// const r = row.original;
//
// const link = constructShareLink(
// r.resourceId,
// r.accessTokenId,
// r.tokenHash
// );
//
// return (
// <div className="flex items-center">
// <Link
// href={link}
// target="_blank"
// rel="noopener noreferrer"
// className="hover:underline mr-2"
// >
// {formatLink(link)}
// </Link>
// <Button
// variant="ghost"
// className="h-6 w-6 p-0"
// onClick={() => {
// navigator.clipboard.writeText(link);
// const originalIcon = document.querySelector(
// `#icon-${r.accessTokenId}`
// );
// if (originalIcon) {
// originalIcon.classList.add("hidden");
// }
// const checkIcon = document.querySelector(
// `#check-icon-${r.accessTokenId}`
// );
// if (checkIcon) {
// checkIcon.classList.remove("hidden");
// setTimeout(() => {
// checkIcon.classList.add("hidden");
// if (originalIcon) {
// originalIcon.classList.remove(
// "hidden"
// );
// }
// }, 2000);
// }
// }}
// >
// <Copy
// id={`icon-${r.accessTokenId}`}
// className="h-4 w-4"
// />
// <Check
// id={`check-icon-${r.accessTokenId}`}
// className="hidden text-green-500 h-4 w-4"
// />
// <span className="sr-only">Copy link</span>
// </Button>
// </div>
// );
// }
// },
{
accessorKey: "createdAt",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("created")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const r = row.original;
return moment(r.createdAt).format("lll");
}
},
{
accessorKey: "expiresAt",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("expires")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const r = row.original;
if (r.expiresAt) {
return moment(r.expiresAt).format("lll");
}
return t("never");
}
},
{
id: "delete",
cell: ({ row }) => {
const resourceRow = row.original;
return (
<div className="flex items-center justify-end space-x-2">
{/* <DropdownMenu> */}
{/* <DropdownMenuTrigger asChild> */}
{/* <Button variant="ghost" className="h-8 w-8 p-0"> */}
{/* <span className="sr-only"> */}
{/* {t("openMenu")} */}
{/* </span> */}
{/* <MoreHorizontal className="h-4 w-4" /> */}
{/* </Button> */}
{/* </DropdownMenuTrigger> */}
{/* <DropdownMenuContent align="end"> */}
{/* <DropdownMenuItem */}
{/* onClick={() => { */}
{/* deleteSharelink( */}
{/* resourceRow.accessTokenId */}
{/* ); */}
{/* }} */}
{/* > */}
{/* <button className="text-red-500"> */}
{/* {t("delete")} */}
{/* </button> */}
{/* </DropdownMenuItem> */}
{/* </DropdownMenuContent> */}
{/* </DropdownMenu> */}
<Button
variant="secondary"
size="sm"
onClick={() =>
deleteSharelink(row.original.accessTokenId)
}
>
{t("delete")}
</Button>
</div>
);
}
}
];
return (
<>
<CreateShareLinkForm
open={isCreateModalOpen}
setOpen={setIsCreateModalOpen}
onCreated={(val) => {
setRows([val, ...rows]);
}}
/>
<ShareLinksDataTable
columns={columns}
data={rows}
createShareLink={() => {
setIsCreateModalOpen(true);
}}
/>
</>
);
}

View File

@@ -0,0 +1,454 @@
"use client";
import { useState, useEffect } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@/components/ui/form";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from "@/components/ui/card";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Progress } from "@/components/ui/progress";
import { SignUpResponse } from "@server/routers/auth";
import { useRouter } from "next/navigation";
import { passwordSchema } from "@server/auth/passwordSchema";
import { AxiosResponse } from "axios";
import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import Image from "next/image";
import { cleanRedirect } from "@app/lib/cleanRedirect";
import { useTranslations } from "next-intl";
import BrandingLogo from "@app/components/BrandingLogo";
import { build } from "@server/build";
import { Check, X } from "lucide-react";
import { cn } from "@app/lib/cn";
// Password strength calculation
const calculatePasswordStrength = (password: string) => {
const requirements = {
length: password.length >= 8,
uppercase: /[A-Z]/.test(password),
lowercase: /[a-z]/.test(password),
number: /[0-9]/.test(password),
special: /[~!`@#$%^&*()_\-+={}[\]|\\:;"'<>,.\/?]/.test(password)
};
const score = Object.values(requirements).filter(Boolean).length;
let strength: "weak" | "medium" | "strong" = "weak";
let color = "bg-red-500";
let percentage = 0;
if (score >= 5) {
strength = "strong";
color = "bg-green-500";
percentage = 100;
} else if (score >= 3) {
strength = "medium";
color = "bg-yellow-500";
percentage = 60;
} else if (score >= 1) {
strength = "weak";
color = "bg-red-500";
percentage = 30;
}
return { requirements, strength, color, percentage, score };
};
type SignupFormProps = {
redirect?: string;
inviteId?: string;
inviteToken?: string;
emailParam?: string;
};
const formSchema = z
.object({
email: z.string().email({ message: "Invalid email address" }),
password: passwordSchema,
confirmPassword: passwordSchema,
agreeToTerms: z.boolean().refine(
(val) => {
if (build === "saas") {
val === true;
}
return true;
},
{
message:
"You must agree to the terms of service and privacy policy"
}
)
})
.refine((data) => data.password === data.confirmPassword, {
path: ["confirmPassword"],
message: "Passwords do not match"
});
export default function SignupForm({
redirect,
inviteId,
inviteToken,
emailParam
}: SignupFormProps) {
const router = useRouter();
const api = createApiClient(useEnvContext());
const t = useTranslations();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [termsAgreedAt, setTermsAgreedAt] = useState<string | null>(null);
const [passwordValue, setPasswordValue] = useState("");
const [confirmPasswordValue, setConfirmPasswordValue] = useState("");
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
email: emailParam || "",
password: "",
confirmPassword: "",
agreeToTerms: false
},
mode: "onChange" // Enable real-time validation
});
const passwordStrength = calculatePasswordStrength(passwordValue);
const doPasswordsMatch = passwordValue.length > 0 && confirmPasswordValue.length > 0 && passwordValue === confirmPasswordValue;
async function onSubmit(values: z.infer<typeof formSchema>) {
const { email, password } = values;
setLoading(true);
const res = await api
.put<AxiosResponse<SignUpResponse>>("/auth/signup", {
email,
password,
inviteId,
inviteToken,
termsAcceptedTimestamp: termsAgreedAt
})
.catch((e) => {
console.error(e);
setError(formatAxiosError(e, t("signupError")));
});
if (res && res.status === 200) {
setError(null);
if (res.data?.data?.emailVerificationRequired) {
if (redirect) {
const safe = cleanRedirect(redirect);
router.push(`/auth/verify-email?redirect=${safe}`);
} else {
router.push("/auth/verify-email");
}
return;
}
if (redirect) {
const safe = cleanRedirect(redirect);
router.push(safe);
} else {
router.push("/");
}
}
setLoading(false);
}
function getSubtitle() {
return t("authCreateAccount");
}
const handleTermsChange = (checked: boolean) => {
if (checked) {
const isoNow = new Date().toISOString();
console.log("Terms agreed at:", isoNow);
setTermsAgreedAt(isoNow);
form.setValue("agreeToTerms", true);
} else {
form.setValue("agreeToTerms", false);
setTermsAgreedAt(null);
}
};
return (
<Card className="w-full max-w-md shadow-md">
<CardHeader className="border-b">
<div className="flex flex-row items-center justify-center">
<BrandingLogo height={58} width={175} />
</div>
<div className="text-center space-y-1 pt-3">
<p className="text-muted-foreground">{getSubtitle()}</p>
</div>
</CardHeader>
<CardContent className="pt-6">
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>{t("email")}</FormLabel>
<FormControl>
<Input
{...field}
disabled={!!emailParam}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<div className="flex items-center gap-2">
<FormLabel>{t("password")}</FormLabel>
{passwordStrength.strength === "strong" && (
<Check className="h-4 w-4 text-green-500" />
)}
</div>
<FormControl>
<div className="relative">
<Input
type="password"
{...field}
onChange={(e) => {
field.onChange(e);
setPasswordValue(e.target.value);
}}
className={cn(
passwordStrength.strength === "strong" && "border-green-500 focus-visible:ring-green-500",
passwordStrength.strength === "medium" && "border-yellow-500 focus-visible:ring-yellow-500",
passwordStrength.strength === "weak" && passwordValue.length > 0 && "border-red-500 focus-visible:ring-red-500"
)}
autoComplete="new-password"
/>
</div>
</FormControl>
{passwordValue.length > 0 && (
<div className="space-y-3 mt-2">
{/* Password Strength Meter */}
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm font-medium text-foreground">{t("passwordStrength")}</span>
<span className={cn(
"text-sm font-semibold",
passwordStrength.strength === "strong" && "text-green-600 dark:text-green-400",
passwordStrength.strength === "medium" && "text-yellow-600 dark:text-yellow-400",
passwordStrength.strength === "weak" && "text-red-600 dark:text-red-400"
)}>
{t(`passwordStrength${passwordStrength.strength.charAt(0).toUpperCase() + passwordStrength.strength.slice(1)}`)}
</span>
</div>
<Progress
value={passwordStrength.percentage}
className="h-2"
/>
</div>
{/* Requirements Checklist */}
<div className="bg-muted rounded-lg p-3 space-y-2">
<div className="text-sm font-medium text-foreground mb-2">{t("passwordRequirements")}</div>
<div className="grid grid-cols-1 gap-1.5">
<div className="flex items-center gap-2">
{passwordStrength.requirements.length ? (
<Check className="h-3.5 w-3.5 text-green-500 flex-shrink-0" />
) : (
<X className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
)}
<span className={cn(
"text-sm",
passwordStrength.requirements.length ? "text-green-600 dark:text-green-400" : "text-muted-foreground"
)}>
{t("passwordRequirementLengthText")}
</span>
</div>
<div className="flex items-center gap-2">
{passwordStrength.requirements.uppercase ? (
<Check className="h-3.5 w-3.5 text-green-500 flex-shrink-0" />
) : (
<X className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
)}
<span className={cn(
"text-sm",
passwordStrength.requirements.uppercase ? "text-green-600 dark:text-green-400" : "text-muted-foreground"
)}>
{t("passwordRequirementUppercaseText")}
</span>
</div>
<div className="flex items-center gap-2">
{passwordStrength.requirements.lowercase ? (
<Check className="h-3.5 w-3.5 text-green-500 flex-shrink-0" />
) : (
<X className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
)}
<span className={cn(
"text-sm",
passwordStrength.requirements.lowercase ? "text-green-600 dark:text-green-400" : "text-muted-foreground"
)}>
{t("passwordRequirementLowercaseText")}
</span>
</div>
<div className="flex items-center gap-2">
{passwordStrength.requirements.number ? (
<Check className="h-3.5 w-3.5 text-green-500 flex-shrink-0" />
) : (
<X className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
)}
<span className={cn(
"text-sm",
passwordStrength.requirements.number ? "text-green-600 dark:text-green-400" : "text-muted-foreground"
)}>
{t("passwordRequirementNumberText")}
</span>
</div>
<div className="flex items-center gap-2">
{passwordStrength.requirements.special ? (
<Check className="h-3.5 w-3.5 text-green-500 flex-shrink-0" />
) : (
<X className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
)}
<span className={cn(
"text-sm",
passwordStrength.requirements.special ? "text-green-600 dark:text-green-400" : "text-muted-foreground"
)}>
{t("passwordRequirementSpecialText")}
</span>
</div>
</div>
</div>
</div>
)}
{/* Only show FormMessage when not showing our custom requirements */}
{passwordValue.length === 0 && <FormMessage />}
</FormItem>
)}
/>
<FormField
control={form.control}
name="confirmPassword"
render={({ field }) => (
<FormItem>
<div className="flex items-center gap-2">
<FormLabel>{t('confirmPassword')}</FormLabel>
{doPasswordsMatch && (
<Check className="h-4 w-4 text-green-500" />
)}
</div>
<FormControl>
<div className="relative">
<Input
type="password"
{...field}
onChange={(e) => {
field.onChange(e);
setConfirmPasswordValue(e.target.value);
}}
className={cn(
doPasswordsMatch && "border-green-500 focus-visible:ring-green-500",
confirmPasswordValue.length > 0 && !doPasswordsMatch && "border-red-500 focus-visible:ring-red-500"
)}
autoComplete="new-password"
/>
</div>
</FormControl>
{confirmPasswordValue.length > 0 && !doPasswordsMatch && (
<p className="text-sm text-red-600 mt-1">
{t("passwordsDoNotMatch")}
</p>
)}
{/* Only show FormMessage when field is empty */}
{confirmPasswordValue.length === 0 && <FormMessage />}
</FormItem>
)}
/>
{build === "saas" && (
<FormField
control={form.control}
name="agreeToTerms"
render={({ field }) => (
<FormItem className="flex flex-row items-center">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={(checked) => {
field.onChange(checked);
handleTermsChange(
checked as boolean
);
}}
/>
</FormControl>
<div className="leading-none">
<FormLabel className="text-sm font-normal">
{t("signUpTerms.IAgreeToThe")}
<a
href="https://digpangolin.com/terms-of-service.html"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
{t(
"signUpTerms.termsOfService"
)}
</a>
{t("signUpTerms.and")}
<a
href="https://digpangolin.com/privacy-policy.html"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
{t(
"signUpTerms.privacyPolicy"
)}
</a>
</FormLabel>
<FormMessage />
</div>
</FormItem>
)}
/>
)}
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<Button type="submit" className="w-full">
{t("createAccount")}
</Button>
</form>
</Form>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,81 @@
"use client";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { InfoIcon } from "lucide-react";
import { useSiteContext } from "@app/hooks/useSiteContext";
import {
InfoSection,
InfoSectionContent,
InfoSections,
InfoSectionTitle
} from "@app/components/InfoSection";
import { useTranslations } from "next-intl";
import { useEnvContext } from "@app/hooks/useEnvContext";
type SiteInfoCardProps = {};
export default function SiteInfoCard({}: SiteInfoCardProps) {
const { site, updateSite } = useSiteContext();
const t = useTranslations();
const { env } = useEnvContext();
const getConnectionTypeString = (type: string) => {
if (type === "newt") {
return "Newt";
} else if (type === "wireguard") {
return "WireGuard";
} else if (type === "local") {
return t("local");
} else {
return t("unknown");
}
};
return (
<Alert>
<AlertDescription>
<InfoSections cols={env.flags.enableClients ? 3 : 2}>
{(site.type == "newt" || site.type == "wireguard") && (
<>
<InfoSection>
<InfoSectionTitle>
{t("status")}
</InfoSectionTitle>
<InfoSectionContent>
{site.online ? (
<div className="text-green-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span>{t("online")}</span>
</div>
) : (
<div className="text-neutral-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-gray-500 rounded-full"></div>
<span>{t("offline")}</span>
</div>
)}
</InfoSectionContent>
</InfoSection>
</>
)}
<InfoSection>
<InfoSectionTitle>
{t("connectionType")}
</InfoSectionTitle>
<InfoSectionContent>
{getConnectionTypeString(site.type)}
</InfoSectionContent>
</InfoSection>
{env.flags.enableClients && site.type == "newt" && (
<InfoSection>
<InfoSectionTitle>Address</InfoSectionTitle>
<InfoSectionContent>
{site.address?.split("/")[0]}
</InfoSectionContent>
</InfoSection>
)}
</InfoSections>
</AlertDescription>
</Alert>
);
}

View File

@@ -0,0 +1,131 @@
import { useState } from "react";
import { Button } from "@app/components/ui/button";
import { MinusCircle, PlusCircle } from "lucide-react";
import {
Credenza,
CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "@app/components/Credenza";
import { useTranslations } from "next-intl";
type SitePriceCalculatorProps = {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
mode: "license" | "additional-sites";
};
export function SitePriceCalculator({
isOpen,
onOpenChange,
mode
}: SitePriceCalculatorProps) {
const [siteCount, setSiteCount] = useState(3);
const pricePerSite = 5;
const licenseFlatRate = 125;
const incrementSites = () => {
setSiteCount((prev) => prev + 1);
};
const decrementSites = () => {
setSiteCount((prev) => (prev > 1 ? prev - 1 : 1));
};
function continueToPayment() {
if (mode === "license") {
// open in new tab
window.open(
`https://payment.fossorial.io/buy/dab98d3d-9976-49b1-9e55-1580059d833f?quantity=${siteCount}`,
"_blank"
);
} else {
window.open(
`https://payment.fossorial.io/buy/2b881c36-ea5d-4c11-8652-9be6810a054f?quantity=${siteCount}`,
"_blank"
);
}
}
const totalCost =
mode === "license"
? licenseFlatRate + siteCount * pricePerSite
: siteCount * pricePerSite;
const t = useTranslations();
return (
<Credenza open={isOpen} onOpenChange={onOpenChange}>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>
{mode === "license"
? t('licensePurchase')
: t('licensePurchaseSites')}
</CredenzaTitle>
<CredenzaDescription>
{t('licensePurchaseDescription', {selectedMode: mode})}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<div className="space-y-6">
<div className="flex flex-col items-center space-y-4">
<div className="text-sm font-medium text-muted-foreground">
{t('numberOfSites')}
</div>
<div className="flex items-center space-x-4">
<Button
variant="ghost"
size="icon"
onClick={decrementSites}
disabled={siteCount <= 1}
aria-label={t('sitestCountDecrease')}
>
<MinusCircle className="h-5 w-5" />
</Button>
<span className="text-3xl w-12 text-center">
{siteCount}
</span>
<Button
variant="ghost"
size="icon"
onClick={incrementSites}
aria-label={t('sitestCountIncrease')}
>
<PlusCircle className="h-5 w-5" />
</Button>
</div>
</div>
<div className="border-t pt-4">
<p className="text-muted-foreground text-sm mt-2 text-center">
{t('licensePricingPage')}
<a
href="https://docs.fossorial.io/pricing"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
{t('pricingPage')}
</a>
.
</p>
</div>
</div>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">{t('cancel')}</Button>
</CredenzaClose>
<Button onClick={continueToPayment}>
{t('pricingPortal')}
</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
);
}

View File

@@ -0,0 +1,43 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { DataTable } from "@app/components/ui/data-table";
import { useTranslations } from "next-intl";
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
createSite?: () => void;
onRefresh?: () => void;
isRefreshing?: boolean;
}
export function SitesDataTable<TData, TValue>({
columns,
data,
createSite,
onRefresh,
isRefreshing
}: DataTableProps<TData, TValue>) {
const t = useTranslations();
return (
<DataTable
columns={columns}
data={data}
persistPageSize="sites-table"
title={t('sites')}
searchPlaceholder={t('searchSitesProgress')}
searchColumn="name"
onAdd={createSite}
addButtonText={t('siteAdd')}
onRefresh={onRefresh}
isRefreshing={isRefreshing}
defaultSort={{
id: "name",
desc: false
}}
/>
);
}

View File

@@ -0,0 +1,104 @@
"use client";
import React, { useState, useEffect } from "react";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { ArrowRight, DockIcon as Docker, Globe, Server, X } from "lucide-react";
import Link from "next/link";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useTranslations } from 'next-intl';
export const SitesSplashCard = () => {
const [isDismissed, setIsDismissed] = useState(true);
const { env } = useEnvContext();
const key = "sites-splash-card-dismissed";
const t = useTranslations();
useEffect(() => {
const dismissed = localStorage.getItem(key);
if (dismissed === "true") {
setIsDismissed(true);
} else {
setIsDismissed(false);
}
}, []);
const handleDismiss = () => {
setIsDismissed(true);
localStorage.setItem(key, "true");
};
if (isDismissed) {
return null;
}
return (
<Card className="w-full mx-auto overflow-hidden mb-8 hidden md:block relative">
<button
onClick={handleDismiss}
className="absolute top-2 right-2 p-2"
aria-label={t('dismiss')}
>
<X className="w-5 h-5" />
</button>
<CardContent className="grid gap-6 p-6 sm:grid-cols-2">
<div className="space-y-4">
<h3 className="text-xl font-semibold flex items-center gap-2">
<Globe className="text-blue-500" />
Newt ({t('recommended')})
</h3>
<p className="text-sm">
{t('siteNewtDescription')}
</p>
<ul className="text-sm text-muted-foreground space-y-2">
<li className="flex items-center gap-2">
<Server className="text-green-500 w-4 h-4" />
{t('siteRunsInDocker')}
</li>
<li className="flex items-center gap-2">
<Server className="text-green-500 w-4 h-4" />
{t('siteRunsInShell')}
</li>
</ul>
<div className="mt-4">
<Link
href="https://docs.digpangolin.com/manage/sites/install-site"
target="_blank"
rel="noopener noreferrer"
>
<Button
className="w-full flex items-center"
variant="secondary"
>
{t('siteInstallNewt')}{" "}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</Link>
</div>
</div>
<div className="space-y-4">
<h3 className="text-xl font-semibold flex items-center gap-2">
{t('siteWg')}
</h3>
<p className="text-sm">
{t('siteWgAnyClients')}
</p>
<ul className="text-sm text-muted-foreground space-y-2">
<li className="flex items-center gap-2">
<Docker className="text-purple-500 w-4 h-4" />
{t('siteWgCompatibleAllClients')}
</li>
<li className="flex items-center gap-2">
<Server className="text-purple-500 w-4 h-4" />
{t('siteWgManualConfigurationRequired')}
</li>
</ul>
</div>
</CardContent>
</Card>
);
};
export default SitesSplashCard;

View File

@@ -0,0 +1,389 @@
"use client";
import { Column, ColumnDef } from "@tanstack/react-table";
import { SitesDataTable } from "@app/components/SitesDataTable";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu";
import { Button } from "@app/components/ui/button";
import {
ArrowRight,
ArrowUpDown,
Check,
MoreHorizontal,
X
} from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { AxiosResponse } from "axios";
import { useState, useEffect } from "react";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { toast } from "@app/hooks/useToast";
import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useTranslations } from "next-intl";
import { parseDataSize } from "@app/lib/dataSize";
import { Badge } from "@app/components/ui/badge";
import { InfoPopup } from "@app/components/ui/info-popup";
export type SiteRow = {
id: number;
nice: string;
name: string;
mbIn: string;
mbOut: string;
orgId: string;
type: "newt" | "wireguard";
newtVersion?: string;
newtUpdateAvailable?: boolean;
online: boolean;
address?: string;
};
type SitesTableProps = {
sites: SiteRow[];
orgId: string;
};
export default function SitesTable({ sites, orgId }: SitesTableProps) {
const router = useRouter();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selectedSite, setSelectedSite] = useState<SiteRow | null>(null);
const [rows, setRows] = useState<SiteRow[]>(sites);
const [isRefreshing, setIsRefreshing] = useState(false);
const api = createApiClient(useEnvContext());
const t = useTranslations();
const { env } = useEnvContext();
// Update local state when props change (e.g., after refresh)
useEffect(() => {
setRows(sites);
}, [sites]);
const refreshData = async () => {
console.log("Data refreshed");
setIsRefreshing(true);
try {
await new Promise((resolve) => setTimeout(resolve, 200));
router.refresh();
} catch (error) {
toast({
title: t("error"),
description: t("refreshError"),
variant: "destructive"
});
} finally {
setIsRefreshing(false);
}
};
const deleteSite = (siteId: number) => {
api.delete(`/site/${siteId}`)
.catch((e) => {
console.error(t("siteErrorDelete"), e);
toast({
variant: "destructive",
title: t("siteErrorDelete"),
description: formatAxiosError(e, t("siteErrorDelete"))
});
})
.then(() => {
router.refresh();
setIsDeleteModalOpen(false);
const newRows = rows.filter((row) => row.id !== siteId);
setRows(newRows);
});
};
const columns: ColumnDef<SiteRow>[] = [
{
accessorKey: "name",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("name")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "online",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("online")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const originalRow = row.original;
if (
originalRow.type == "newt" ||
originalRow.type == "wireguard"
) {
if (originalRow.online) {
return (
<span className="text-green-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span>{t("online")}</span>
</span>
);
} else {
return (
<span className="text-neutral-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-gray-500 rounded-full"></div>
<span>{t("offline")}</span>
</span>
);
}
} else {
return <span>-</span>;
}
}
},
{
accessorKey: "nice",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
className="hidden md:flex whitespace-nowrap"
>
{t("site")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
return (
<div className="hidden md:block whitespace-nowrap">
{row.original.nice}
</div>
);
}
},
{
accessorKey: "mbIn",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("dataIn")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
sortingFn: (rowA, rowB) =>
parseDataSize(rowA.original.mbIn) -
parseDataSize(rowB.original.mbIn)
},
{
accessorKey: "mbOut",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("dataOut")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
sortingFn: (rowA, rowB) =>
parseDataSize(rowA.original.mbOut) -
parseDataSize(rowB.original.mbOut)
},
{
accessorKey: "type",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("connectionType")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const originalRow = row.original;
if (originalRow.type === "newt") {
return (
<div className="flex items-center space-x-1">
<Badge variant="secondary">
<div className="flex items-center space-x-2">
<span>Newt</span>
{originalRow.newtVersion && (
<span className="text-xs text-gray-500">
v{originalRow.newtVersion}
</span>
)}
</div>
</Badge>
{originalRow.newtUpdateAvailable && (
<InfoPopup
info={t("newtUpdateAvailableInfo")}
/>
)}
</div>
);
}
if (originalRow.type === "wireguard") {
return (
<div className="flex items-center space-x-2">
<span>WireGuard</span>
</div>
);
}
if (originalRow.type === "local") {
return (
<div className="flex items-center space-x-2">
<span>{t("local")}</span>
</div>
);
}
}
},
...(env.flags.enableClients ? [{
accessorKey: "address",
header: ({ column }: { column: Column<SiteRow, unknown> }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Address
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
}] : []),
{
id: "actions",
cell: ({ row }) => {
const siteRow = row.original;
return (
<div className="flex items-center justify-end gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<Link
className="block w-full"
href={`/${siteRow.orgId}/settings/sites/${siteRow.nice}`}
>
<DropdownMenuItem>
{t("viewSettings")}
</DropdownMenuItem>
</Link>
<DropdownMenuItem
onClick={() => {
setSelectedSite(siteRow);
setIsDeleteModalOpen(true);
}}
>
<span className="text-red-500">
{t("delete")}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Link
href={`/${siteRow.orgId}/settings/sites/${siteRow.nice}`}
>
<Button variant={"secondary"} size="sm">
{t("edit")}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</Link>
</div>
);
}
}
];
return (
<>
{selectedSite && (
<ConfirmDeleteDialog
open={isDeleteModalOpen}
setOpen={(val) => {
setIsDeleteModalOpen(val);
setSelectedSite(null);
}}
dialog={
<div className="space-y-4">
<p>
{t("siteQuestionRemove", {
selectedSite:
selectedSite?.name || selectedSite?.id
})}
</p>
<p>{t("siteMessageRemove")}</p>
<p>{t("siteMessageConfirm")}</p>
</div>
}
buttonText={t("siteConfirmDelete")}
onConfirm={async () => deleteSite(selectedSite!.id)}
string={selectedSite.name}
title={t("siteDelete")}
/>
)}
<SitesDataTable
columns={columns}
data={rows}
createSite={() =>
router.push(`/${orgId}/settings/sites/create`)
}
onRefresh={refreshData}
isRefreshing={isRefreshing}
/>
</>
);
}

View File

@@ -0,0 +1,35 @@
"use client";
import {
ColumnDef,
} from "@tanstack/react-table";
import { DataTable } from "@app/components/ui/data-table";
import { useTranslations } from 'next-intl';
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
inviteUser?: () => void;
}
export function UsersDataTable<TData, TValue>({
columns,
data,
inviteUser
}: DataTableProps<TData, TValue>) {
const t = useTranslations();
return (
<DataTable
columns={columns}
data={data}
persistPageSize="users-table"
title={t('users')}
searchPlaceholder={t('accessUsersSearch')}
searchColumn="email"
onAdd={inviteUser}
addButtonText={t('accessUserCreate')}
/>
);
}

View File

@@ -0,0 +1,284 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu";
import { Button } from "@app/components/ui/button";
import { ArrowRight, ArrowUpDown, Crown, MoreHorizontal } from "lucide-react";
import { UsersDataTable } from "@app/components/UsersDataTable";
import { useState } from "react";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { useOrgContext } from "@app/hooks/useOrgContext";
import { toast } from "@app/hooks/useToast";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useUserContext } from "@app/hooks/useUserContext";
import { useTranslations } from "next-intl";
export type UserRow = {
id: string;
email: string | null;
displayUsername: string | null;
username: string;
name: string | null;
idpId: number | null;
idpName: string;
type: string;
status: string;
role: string;
isOwner: boolean;
};
type UsersTableProps = {
users: UserRow[];
};
export default function UsersTable({ users: u }: UsersTableProps) {
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selectedUser, setSelectedUser] = useState<UserRow | null>(null);
const [users, setUsers] = useState<UserRow[]>(u);
const router = useRouter();
const api = createApiClient(useEnvContext());
const { user, updateUser } = useUserContext();
const { org } = useOrgContext();
const t = useTranslations();
const columns: ColumnDef<UserRow>[] = [
{
accessorKey: "displayUsername",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("username")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "idpName",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("identityProvider")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "role",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("role")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const userRow = row.original;
return (
<div className="flex flex-row items-center gap-2">
{userRow.isOwner && (
<Crown className="w-4 h-4 text-yellow-600" />
)}
<span>{userRow.role}</span>
</div>
);
}
},
{
id: "actions",
cell: ({ row }) => {
const userRow = row.original;
return (
<div className="flex items-center justify-end">
<>
<div>
{userRow.isOwner && (
<MoreHorizontal className="h-4 w-4 opacity-0" />
)}
{!userRow.isOwner && (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-8 w-8 p-0"
>
<span className="sr-only">
{t("openMenu")}
</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<Link
href={`/${org?.org.orgId}/settings/access/users/${userRow.id}`}
className="block w-full"
>
<DropdownMenuItem>
{t("accessUsersManage")}
</DropdownMenuItem>
</Link>
{`${userRow.username}-${userRow.idpId}` !==
`${user?.username}-${userRow.idpId}` && (
<DropdownMenuItem
onClick={() => {
setIsDeleteModalOpen(
true
);
setSelectedUser(
userRow
);
}}
>
<span className="text-red-500">
{t(
"accessUserRemove"
)}
</span>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</>
)}
</div>
</>
{userRow.isOwner && (
<Button
variant={"secondary"}
className="ml-2"
size="sm"
disabled={true}
>
{t("manage")}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
)}
{!userRow.isOwner && (
<Link
href={`/${org?.org.orgId}/settings/access/users/${userRow.id}`}
>
<Button
variant={"secondary"}
className="ml-2"
size="sm"
disabled={userRow.isOwner}
>
{t("manage")}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</Link>
)}
</div>
);
}
}
];
async function removeUser() {
if (selectedUser) {
const res = await api
.delete(`/org/${org!.org.orgId}/user/${selectedUser.id}`)
.catch((e) => {
toast({
variant: "destructive",
title: t("userErrorOrgRemove"),
description: formatAxiosError(
e,
t("userErrorOrgRemoveDescription")
)
});
});
if (res && res.status === 200) {
toast({
variant: "default",
title: t("userOrgRemoved"),
description: t("userOrgRemovedDescription", {
email: selectedUser.email || ""
})
});
setUsers((prev) =>
prev.filter((u) => u.id !== selectedUser?.id)
);
}
}
setIsDeleteModalOpen(false);
}
return (
<>
<ConfirmDeleteDialog
open={isDeleteModalOpen}
setOpen={(val) => {
setIsDeleteModalOpen(val);
setSelectedUser(null);
}}
dialog={
<div className="space-y-4">
<p>
{t("userQuestionOrgRemove", {
email:
selectedUser?.email ||
selectedUser?.name ||
selectedUser?.username ||
""
})}
</p>
<p>{t("userMessageOrgRemove")}</p>
<p>{t("userMessageOrgConfirm")}</p>
</div>
}
buttonText={t("userRemoveOrgConfirm")}
onConfirm={removeUser}
string={
selectedUser?.email ||
selectedUser?.name ||
selectedUser?.username ||
""
}
title={t("userRemoveOrg")}
/>
<UsersDataTable
columns={columns}
data={users}
inviteUser={() => {
router.push(
`/${org?.org.orgId}/settings/access/users/create`
);
}}
/>
</>
);
}

View File

@@ -0,0 +1,126 @@
"use client";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { ValidateOidcUrlCallbackResponse } from "@server/routers/idp";
import { AxiosResponse } from "axios";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import {
Card,
CardHeader,
CardTitle,
CardContent,
CardDescription
} from "@/components/ui/card";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Loader2, CheckCircle2, AlertCircle } from "lucide-react";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import { useTranslations } from "next-intl";
type ValidateOidcTokenParams = {
orgId: string;
idpId: string;
code: string | undefined;
expectedState: string | undefined;
stateCookie: string | undefined;
idp: { name: string };
};
export default function ValidateOidcToken(props: ValidateOidcTokenParams) {
const { env } = useEnvContext();
const api = createApiClient({ env });
const router = useRouter();
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const { licenseStatus, isLicenseViolation } = useLicenseStatusContext();
const t = useTranslations();
useEffect(() => {
async function validate() {
setLoading(true);
console.log(t('idpOidcTokenValidating'), {
code: props.code,
expectedState: props.expectedState,
stateCookie: props.stateCookie
});
if (isLicenseViolation()) {
await new Promise((resolve) => setTimeout(resolve, 5000));
}
try {
const res = await api.post<
AxiosResponse<ValidateOidcUrlCallbackResponse>
>(`/auth/idp/${props.idpId}/oidc/validate-callback`, {
code: props.code,
state: props.expectedState,
storedState: props.stateCookie
});
console.log(t('idpOidcTokenResponse'), res.data);
const redirectUrl = res.data.data.redirectUrl;
if (!redirectUrl) {
router.push("/");
}
setLoading(false);
await new Promise((resolve) => setTimeout(resolve, 100));
if (redirectUrl.startsWith("http")) {
window.location.href = res.data.data.redirectUrl; // this is validated by the parent using this component
} else {
router.push(res.data.data.redirectUrl);
}
} catch (e) {
setError(formatAxiosError(e, t('idpErrorOidcTokenValidating')));
} finally {
setLoading(false);
}
}
validate();
}, []);
return (
<div className="flex items-center justify-center min-h-screen">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>{t('idpConnectingTo', {name: props.idp.name})}</CardTitle>
<CardDescription>{t('idpConnectingToDescription')}</CardDescription>
</CardHeader>
<CardContent className="flex flex-col items-center space-y-4">
{loading && (
<div className="flex items-center space-x-2">
<Loader2 className="h-5 w-5 animate-spin" />
<span>{t('idpConnectingToProcess')}</span>
</div>
)}
{!loading && !error && (
<div className="flex items-center space-x-2 text-green-600">
<CheckCircle2 className="h-5 w-5" />
<span>{t('idpConnectingToFinished')}</span>
</div>
)}
{error && (
<Alert variant="destructive" className="w-full">
<AlertCircle className="h-5 w-5" />
<AlertDescription className="flex flex-col space-y-2">
<span>
{t('idpErrorConnectingTo', {name: props.idp.name})}
</span>
<span className="text-xs">{error}</span>
</AlertDescription>
</Alert>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,256 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from "@/components/ui/card";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
InputOTP,
InputOTPGroup,
InputOTPSlot
} from "@/components/ui/input-otp";
import { AxiosResponse } from "axios";
import { VerifyEmailResponse } from "@server/routers/auth";
import { ArrowRight, IdCard, Loader2 } from "lucide-react";
import { Alert, AlertDescription } from "./ui/alert";
import { toast } from "@app/hooks/useToast";
import { useRouter } from "next/navigation";
import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { cleanRedirect } from "@app/lib/cleanRedirect";
import { useTranslations } from "next-intl";
export type VerifyEmailFormProps = {
email: string;
redirect?: string;
};
export default function VerifyEmailForm({
email,
redirect
}: VerifyEmailFormProps) {
const router = useRouter();
const t = useTranslations();
const [error, setError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [isResending, setIsResending] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const api = createApiClient(useEnvContext());
function logout() {
api.post("/auth/logout")
.catch((e) => {
console.error(t("logoutError"), e);
toast({
title: t("logoutError"),
description: formatAxiosError(e, t("logoutError"))
});
})
.then(() => {
router.push("/auth/login");
router.refresh();
});
}
const FormSchema = z.object({
email: z.string().email({ message: t("emailInvalid") }),
pin: z.string().min(8, {
message: t("verificationCodeLengthRequirements")
})
});
const form = useForm<z.infer<typeof FormSchema>>({
resolver: zodResolver(FormSchema),
defaultValues: {
email: email,
pin: ""
}
});
async function onSubmit(data: z.infer<typeof FormSchema>) {
setIsSubmitting(true);
const res = await api
.post<AxiosResponse<VerifyEmailResponse>>("/auth/verify-email", {
code: data.pin
})
.catch((e) => {
setError(formatAxiosError(e, t("errorOccurred")));
console.error(t("emailErrorVerify"), e);
setIsSubmitting(false);
});
if (res && res.data?.data?.valid) {
setError(null);
setSuccessMessage(t("emailVerified"));
setTimeout(() => {
if (redirect) {
const safe = cleanRedirect(redirect);
router.push(safe);
} else {
router.push("/");
}
setIsSubmitting(false);
}, 1500);
}
}
async function handleResendCode() {
setIsResending(true);
const res = await api.post("/auth/verify-email/request").catch((e) => {
setError(formatAxiosError(e, t("errorOccurred")));
console.error(t("verificationCodeErrorResend"), e);
});
if (res) {
setError(null);
toast({
variant: "default",
title: t("verificationCodeResend"),
description: t("verificationCodeResendDescription")
});
}
setIsResending(false);
}
return (
<div>
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>{t("emailVerify")}</CardTitle>
<CardDescription>
{t("emailVerifyDescription")}
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-center text-muted-foreground mb-4">
{email}
</p>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
id="verify-email-form"
>
<FormField
control={form.control}
name="pin"
render={({ field }) => (
<FormItem>
<FormControl>
<div className="flex justify-center">
<InputOTP
maxLength={8}
{...field}
>
<InputOTPGroup className="flex">
<InputOTPSlot
index={0}
/>
<InputOTPSlot
index={1}
/>
<InputOTPSlot
index={2}
/>
<InputOTPSlot
index={3}
/>
<InputOTPSlot
index={4}
/>
<InputOTPSlot
index={5}
/>
<InputOTPSlot
index={6}
/>
<InputOTPSlot
index={7}
/>
</InputOTPGroup>
</InputOTP>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="text-center text-muted-foreground">
<Button
type="button"
variant="link"
onClick={handleResendCode}
disabled={isResending}
>
{isResending
? t("emailVerifyResendProgress")
: t("emailVerifyResend")}
</Button>
</div>
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{successMessage && (
<Alert variant="success">
<AlertDescription>
{successMessage}
</AlertDescription>
</Alert>
)}
<Button
type="submit"
className="w-full"
disabled={isSubmitting}
form="verify-email-form"
>
{isSubmitting && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{t("submit")}
</Button>
<Button
type="button"
variant={"secondary"}
className="w-full"
onClick={logout}
>
Log in with another account
</Button>
</form>
</Form>
</CardContent>
</Card>
</div>
);
}