clean up ui pass 1

This commit is contained in:
miloschwartz 2025-06-30 09:33:48 -07:00
parent 3b6a44e683
commit a0381eb2c6
No known key found for this signature in database
82 changed files with 17618 additions and 17258 deletions

View file

@ -1136,5 +1136,13 @@
"initialSetupTitle": "Initial Server Setup", "initialSetupTitle": "Initial Server Setup",
"initialSetupDescription": "Create the intial server admin account. Only one server admin can exist. You can always change these credentials later.", "initialSetupDescription": "Create the intial server admin account. Only one server admin can exist. You can always change these credentials later.",
"createAdminAccount": "Create Admin Account", "createAdminAccount": "Create Admin Account",
"setupErrorCreateAdmin": "An error occurred while creating the server admin account." "setupErrorCreateAdmin": "An error occurred while creating the server admin account.",
"documentation": "Documentation",
"saveAllSettings": "Save All Settings",
"settingsUpdated": "Settings updated",
"settingsUpdatedDescription": "All settings have been updated successfully",
"settingsErrorUpdate": "Failed to update settings",
"settingsErrorUpdateDescription": "An error occurred while updating settings",
"sidebarCollapse": "Collapse",
"sidebarExpand": "Expand"
} }

28345
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -49,6 +49,7 @@
"@radix-ui/react-switch": "1.2.5", "@radix-ui/react-switch": "1.2.5",
"@radix-ui/react-tabs": "1.1.12", "@radix-ui/react-tabs": "1.1.12",
"@radix-ui/react-toast": "1.2.14", "@radix-ui/react-toast": "1.2.14",
"@radix-ui/react-tooltip": "^1.2.7",
"@react-email/components": "0.0.41", "@react-email/components": "0.0.41",
"@react-email/render": "^1.1.2", "@react-email/render": "^1.1.2",
"@react-email/tailwind": "1.0.5", "@react-email/tailwind": "1.0.5",

View file

@ -95,7 +95,7 @@ export class Config {
? "true" ? "true"
: "false"; : "false";
process.env.FLAGS_ENABLE_CLIENTS = parsedConfig.flags?.disable_clients process.env.FLAGS_ENABLE_CLIENTS = parsedConfig.flags?.enable_clients
? "true" ? "true"
: "false"; : "false";

View file

@ -41,7 +41,7 @@ import { createNewt, getNewtToken } from "./newt";
import { getOlmToken } from "./olm"; import { getOlmToken } from "./olm";
import rateLimit from "express-rate-limit"; import rateLimit from "express-rate-limit";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import { verifyClientsEnabled } from "@server/middlewares/verifyClientsEnabled"; import { verifyClientsEnabled } from "@server/middlewares/verifyClintsEnabled";
// Root routes // Root routes
export const unauthenticated = Router(); export const unauthenticated = Router();

View file

@ -8,7 +8,6 @@ import { AxiosResponse } from "axios";
import { authCookieHeader } from "@app/lib/api/cookies"; import { authCookieHeader } from "@app/lib/api/cookies";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { Layout } from "@app/components/Layout"; import { Layout } from "@app/components/Layout";
import { orgLangingNavItems, orgNavItems, rootNavItems } from "../navigation";
import { ListUserOrgsResponse } from "@server/routers/org"; import { ListUserOrgsResponse } from "@server/routers/org";
type OrgPageProps = { type OrgPageProps = {
@ -60,7 +59,7 @@ export default async function OrgPage(props: OrgPageProps) {
return ( return (
<UserProvider user={user}> <UserProvider user={user}>
<Layout orgId={orgId} navItems={orgLangingNavItems} orgs={orgs}> <Layout orgId={orgId} navItems={[]} orgs={orgs}>
{overview && ( {overview && (
<div className="w-full max-w-4xl mx-auto md:mt-32 mt-4"> <div className="w-full max-w-4xl mx-auto md:mt-32 mt-4">
<OrganizationLandingCard <OrganizationLandingCard

View file

@ -18,6 +18,7 @@ import { toast } from "@app/hooks/useToast";
import { createApiClient } from "@app/lib/api"; import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import moment from "moment";
export type InvitationRow = { export type InvitationRow = {
id: string; id: string;
@ -46,63 +47,69 @@ export default function InvitationsTable({
const { org } = useOrgContext(); const { org } = useOrgContext();
const columns: ColumnDef<InvitationRow>[] = [ const columns: ColumnDef<InvitationRow>[] = [
{
id: "dots",
cell: ({ row }) => {
const invitation = 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={() => {
setIsRegenerateModalOpen(true);
setSelectedInvitation(invitation);
}}
>
<span>{t('inviteRegenerate')}</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setIsDeleteModalOpen(true);
setSelectedInvitation(invitation);
}}
>
<span className="text-red-500">
{t('inviteRemove')}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
},
{ {
accessorKey: "email", accessorKey: "email",
header: t('email') header: t("email")
}, },
{ {
accessorKey: "expiresAt", accessorKey: "expiresAt",
header: t('expiresAt'), header: t("expiresAt"),
cell: ({ row }) => { cell: ({ row }) => {
const expiresAt = new Date(row.original.expiresAt); const expiresAt = new Date(row.original.expiresAt);
const isExpired = expiresAt < new Date(); const isExpired = expiresAt < new Date();
return ( return (
<span className={isExpired ? "text-red-500" : ""}> <span className={isExpired ? "text-red-500" : ""}>
{expiresAt.toLocaleString()} {moment(expiresAt).format("lll")}
</span> </span>
); );
} }
}, },
{ {
accessorKey: "role", accessorKey: "role",
header: t('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>
);
}
} }
]; ];
@ -115,16 +122,18 @@ export default function InvitationsTable({
.catch((e) => { .catch((e) => {
toast({ toast({
variant: "destructive", variant: "destructive",
title: t('inviteRemoveError'), title: t("inviteRemoveError"),
description: t('inviteRemoveErrorDescription') description: t("inviteRemoveErrorDescription")
}); });
}); });
if (res && res.status === 200) { if (res && res.status === 200) {
toast({ toast({
variant: "default", variant: "default",
title: t('inviteRemoved'), title: t("inviteRemoved"),
description: t('inviteRemovedDescription', {email: selectedInvitation.email}) description: t("inviteRemovedDescription", {
email: selectedInvitation.email
})
}); });
setInvitations((prev) => setInvitations((prev) =>
@ -148,20 +157,18 @@ export default function InvitationsTable({
dialog={ dialog={
<div className="space-y-4"> <div className="space-y-4">
<p> <p>
{t('inviteQuestionRemove', {email: selectedInvitation?.email || ""})} {t("inviteQuestionRemove", {
</p> email: selectedInvitation?.email || ""
<p> })}
{t('inviteMessageRemove')}
</p>
<p>
{t('inviteMessageConfirm')}
</p> </p>
<p>{t("inviteMessageRemove")}</p>
<p>{t("inviteMessageConfirm")}</p>
</div> </div>
} }
buttonText={t('inviteRemoveConfirm')} buttonText={t("inviteRemoveConfirm")}
onConfirm={removeInvitation} onConfirm={removeInvitation}
string={selectedInvitation?.email ?? ""} string={selectedInvitation?.email ?? ""}
title={t('inviteRemove')} title={t("inviteRemove")}
/> />
<RegenerateInvitationForm <RegenerateInvitationForm
open={isRegenerateModalOpen} open={isRegenerateModalOpen}

View file

@ -201,7 +201,7 @@ export default function RegenerateInvitationForm({
setValidHours(parseInt(value)) setValidHours(parseInt(value))
} }
> >
<SelectTrigger> <SelectTrigger className="w-full">
<SelectValue placeholder={t('inviteValidityPeriodSelect')} /> <SelectValue placeholder={t('inviteValidityPeriodSelect')} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>

View file

@ -159,7 +159,6 @@ export default function DeleteRoleForm({
</CredenzaDescription> </CredenzaDescription>
</CredenzaHeader> </CredenzaHeader>
<CredenzaBody> <CredenzaBody>
<div className="space-y-4">
<div className="space-y-4"> <div className="space-y-4">
<p> <p>
{t('accessRoleQuestionRemove', {name: roleToDelete.name})} {t('accessRoleQuestionRemove', {name: roleToDelete.name})}
@ -210,13 +209,13 @@ export default function DeleteRoleForm({
/> />
</form> </form>
</Form> </Form>
</div>
</CredenzaBody> </CredenzaBody>
<CredenzaFooter> <CredenzaFooter>
<CredenzaClose asChild> <CredenzaClose asChild>
<Button variant="outline">{t('close')}</Button> <Button variant="outline">{t('close')}</Button>
</CredenzaClose> </CredenzaClose>
<Button <Button
variant="destructive"
type="submit" type="submit"
form="remove-role-form" form="remove-role-form"
loading={loading} loading={loading}

View file

@ -19,7 +19,7 @@ import CreateRoleForm from "./CreateRoleForm";
import DeleteRoleForm from "./DeleteRoleForm"; import DeleteRoleForm from "./DeleteRoleForm";
import { createApiClient } from "@app/lib/api"; import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import { useTranslations } from 'next-intl'; import { useTranslations } from "next-intl";
export type RoleRow = Role; export type RoleRow = Role;
@ -42,49 +42,6 @@ export default function UsersTable({ roles: r }: RolesTableProps) {
const t = useTranslations(); const t = useTranslations();
const columns: ColumnDef<RoleRow>[] = [ const columns: ColumnDef<RoleRow>[] = [
{
id: "actions",
cell: ({ row }) => {
const roleRow = row.original;
return (
<>
<div>
{roleRow.isAdmin && (
<MoreHorizontal className="h-4 w-4 opacity-0" />
)}
{!roleRow.isAdmin && (
<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);
setUserToRemove(roleRow);
}}
>
<span className="text-red-500">
{t('accessRoleDelete')}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</>
);
}
},
{ {
accessorKey: "name", accessorKey: "name",
header: ({ column }) => { header: ({ column }) => {
@ -95,7 +52,7 @@ export default function UsersTable({ roles: r }: RolesTableProps) {
column.toggleSorting(column.getIsSorted() === "asc") column.toggleSorting(column.getIsSorted() === "asc")
} }
> >
{t('name')} {t("name")}
<ArrowUpDown className="ml-2 h-4 w-4" /> <ArrowUpDown className="ml-2 h-4 w-4" />
</Button> </Button>
); );
@ -103,7 +60,29 @@ export default function UsersTable({ roles: r }: RolesTableProps) {
}, },
{ {
accessorKey: "description", accessorKey: "description",
header: t('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>
);
}
} }
]; ];

View file

@ -6,8 +6,6 @@ import { cache } from "react";
import OrgProvider from "@app/providers/OrgProvider"; import OrgProvider from "@app/providers/OrgProvider";
import { ListRolesResponse } from "@server/routers/role"; import { ListRolesResponse } from "@server/routers/role";
import RolesTable, { RoleRow } from "./RolesTable"; import RolesTable, { RoleRow } from "./RolesTable";
import { SidebarSettings } from "@app/components/SidebarSettings";
import AccessPageHeaderAndNav from "../AccessPageHeaderAndNav";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { getTranslations } from 'next-intl/server'; import { getTranslations } from 'next-intl/server';

View file

@ -20,7 +20,7 @@ import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api"; import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import { useUserContext } from "@app/hooks/useUserContext"; import { useUserContext } from "@app/hooks/useUserContext";
import { useTranslations } from 'next-intl'; import { useTranslations } from "next-intl";
export type UserRow = { export type UserRow = {
id: string; id: string;
@ -51,65 +51,6 @@ export default function UsersTable({ users: u }: UsersTableProps) {
const t = useTranslations(); const t = useTranslations();
const columns: ColumnDef<UserRow>[] = [ const columns: ColumnDef<UserRow>[] = [
{
id: "dots",
cell: ({ row }) => {
const userRow = row.original;
return (
<>
<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>
</>
);
}
},
{ {
accessorKey: "displayUsername", accessorKey: "displayUsername",
header: ({ column }) => { header: ({ column }) => {
@ -120,7 +61,7 @@ export default function UsersTable({ users: u }: UsersTableProps) {
column.toggleSorting(column.getIsSorted() === "asc") column.toggleSorting(column.getIsSorted() === "asc")
} }
> >
{t('username')} {t("username")}
<ArrowUpDown className="ml-2 h-4 w-4" /> <ArrowUpDown className="ml-2 h-4 w-4" />
</Button> </Button>
); );
@ -136,7 +77,7 @@ export default function UsersTable({ users: u }: UsersTableProps) {
column.toggleSorting(column.getIsSorted() === "asc") column.toggleSorting(column.getIsSorted() === "asc")
} }
> >
{t('identityProvider')} {t("identityProvider")}
<ArrowUpDown className="ml-2 h-4 w-4" /> <ArrowUpDown className="ml-2 h-4 w-4" />
</Button> </Button>
); );
@ -152,7 +93,7 @@ export default function UsersTable({ users: u }: UsersTableProps) {
column.toggleSorting(column.getIsSorted() === "asc") column.toggleSorting(column.getIsSorted() === "asc")
} }
> >
{t('role')} {t("role")}
<ArrowUpDown className="ml-2 h-4 w-4" /> <ArrowUpDown className="ml-2 h-4 w-4" />
</Button> </Button>
); );
@ -176,12 +117,68 @@ export default function UsersTable({ users: u }: UsersTableProps) {
const userRow = row.original; const userRow = row.original;
return ( return (
<div className="flex items-center justify-end"> <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 && ( {userRow.isOwner && (
<Button <Button
variant="ghost" variant={"secondary"}
className="opacity-0 cursor-default" className="ml-2"
size="sm"
disabled={true}
> >
{t('placeholder')} {t("manage")}
<ArrowRight className="ml-2 w-4 h-4" />
</Button> </Button>
)} )}
{!userRow.isOwner && ( {!userRow.isOwner && (
@ -189,10 +186,12 @@ export default function UsersTable({ users: u }: UsersTableProps) {
href={`/${org?.org.orgId}/settings/access/users/${userRow.id}`} href={`/${org?.org.orgId}/settings/access/users/${userRow.id}`}
> >
<Button <Button
variant={"outlinePrimary"} variant={"secondary"}
className="ml-2" className="ml-2"
size="sm"
disabled={userRow.isOwner}
> >
{t('manage')} {t("manage")}
<ArrowRight className="ml-2 w-4 h-4" /> <ArrowRight className="ml-2 w-4 h-4" />
</Button> </Button>
</Link> </Link>
@ -210,10 +209,10 @@ export default function UsersTable({ users: u }: UsersTableProps) {
.catch((e) => { .catch((e) => {
toast({ toast({
variant: "destructive", variant: "destructive",
title: t('userErrorOrgRemove'), title: t("userErrorOrgRemove"),
description: formatAxiosError( description: formatAxiosError(
e, e,
t('userErrorOrgRemoveDescription') t("userErrorOrgRemoveDescription")
) )
}); });
}); });
@ -221,8 +220,10 @@ export default function UsersTable({ users: u }: UsersTableProps) {
if (res && res.status === 200) { if (res && res.status === 200) {
toast({ toast({
variant: "default", variant: "default",
title: t('userOrgRemoved'), title: t("userOrgRemoved"),
description: t('userOrgRemovedDescription', {email: selectedUser.email || ""}) description: t("userOrgRemovedDescription", {
email: selectedUser.email || ""
})
}); });
setUsers((prev) => setUsers((prev) =>
@ -244,19 +245,21 @@ export default function UsersTable({ users: u }: UsersTableProps) {
dialog={ dialog={
<div className="space-y-4"> <div className="space-y-4">
<p> <p>
{t('userQuestionOrgRemove', {email: selectedUser?.email || selectedUser?.name || selectedUser?.username || ""})} {t("userQuestionOrgRemove", {
email:
selectedUser?.email ||
selectedUser?.name ||
selectedUser?.username ||
""
})}
</p> </p>
<p> <p>{t("userMessageOrgRemove")}</p>
{t('userMessageOrgRemove')}
</p>
<p> <p>{t("userMessageOrgConfirm")}</p>
{t('userMessageOrgConfirm')}
</p>
</div> </div>
} }
buttonText={t('userRemoveOrgConfirm')} buttonText={t("userRemoveOrgConfirm")}
onConfirm={removeUser} onConfirm={removeUser}
string={ string={
selectedUser?.email || selectedUser?.email ||
@ -264,14 +267,16 @@ export default function UsersTable({ users: u }: UsersTableProps) {
selectedUser?.username || selectedUser?.username ||
"" ""
} }
title={t('userRemoveOrg')} title={t("userRemoveOrg")}
/> />
<UsersDataTable <UsersDataTable
columns={columns} columns={columns}
data={users} data={users}
inviteUser={() => { inviteUser={() => {
router.push(`/${org?.org.orgId}/settings/access/users/create`); router.push(
`/${org?.org.orgId}/settings/access/users/create`
);
}} }}
/> />
</> </>

View file

@ -5,17 +5,9 @@ import { authCookieHeader } from "@app/lib/api/cookies";
import { GetOrgUserResponse } from "@server/routers/user"; import { GetOrgUserResponse } from "@server/routers/user";
import OrgUserProvider from "@app/providers/OrgUserProvider"; import OrgUserProvider from "@app/providers/OrgUserProvider";
import { HorizontalTabs } from "@app/components/HorizontalTabs"; import { HorizontalTabs } from "@app/components/HorizontalTabs";
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator
} from "@app/components/ui/breadcrumb";
import Link from "next/link";
import { cache } from "react"; import { cache } from "react";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { getTranslations } from 'next-intl/server'; import { getTranslations } from "next-intl/server";
interface UserLayoutProps { interface UserLayoutProps {
children: React.ReactNode; children: React.ReactNode;
@ -45,7 +37,7 @@ export default async function UserLayoutProps(props: UserLayoutProps) {
const navItems = [ const navItems = [
{ {
title: t('accessControls'), title: t("accessControls"),
href: "/{orgId}/settings/access/users/{userId}/access-controls" href: "/{orgId}/settings/access/users/{userId}/access-controls"
} }
]; ];
@ -54,12 +46,10 @@ export default async function UserLayoutProps(props: UserLayoutProps) {
<> <>
<SettingsSectionTitle <SettingsSectionTitle
title={`${user?.email}`} title={`${user?.email}`}
description={t('userDescription2')} description={t("userDescription2")}
/> />
<OrgUserProvider orgUser={user}> <OrgUserProvider orgUser={user}>
<HorizontalTabs items={navItems}> <HorizontalTabs items={navItems}>{children}</HorizontalTabs>
{children}
</HorizontalTabs>
</OrgUserProvider> </OrgUserProvider>
</> </>
); );

View file

@ -78,40 +78,42 @@ export default function Page() {
const [dataLoaded, setDataLoaded] = useState(false); const [dataLoaded, setDataLoaded] = useState(false);
const internalFormSchema = z.object({ const internalFormSchema = z.object({
email: z.string().email({ message: t('emailInvalid') }), email: z.string().email({ message: t("emailInvalid") }),
validForHours: z.string().min(1, { message: t('inviteValidityDuration') }), validForHours: z
roleId: z.string().min(1, { message: t('accessRoleSelectPlease') }) .string()
.min(1, { message: t("inviteValidityDuration") }),
roleId: z.string().min(1, { message: t("accessRoleSelectPlease") })
}); });
const externalFormSchema = z.object({ const externalFormSchema = z.object({
username: z.string().min(1, { message: t('usernameRequired') }), username: z.string().min(1, { message: t("usernameRequired") }),
email: z email: z
.string() .string()
.email({ message: t('emailInvalid') }) .email({ message: t("emailInvalid") })
.optional() .optional()
.or(z.literal("")), .or(z.literal("")),
name: z.string().optional(), name: z.string().optional(),
roleId: z.string().min(1, { message: t('accessRoleSelectPlease') }), roleId: z.string().min(1, { message: t("accessRoleSelectPlease") }),
idpId: z.string().min(1, { message: t('idpSelectPlease') }) idpId: z.string().min(1, { message: t("idpSelectPlease") })
}); });
const formatIdpType = (type: string) => { const formatIdpType = (type: string) => {
switch (type.toLowerCase()) { switch (type.toLowerCase()) {
case "oidc": case "oidc":
return t('idpGenericOidc'); return t("idpGenericOidc");
default: default:
return type; return type;
} }
}; };
const validFor = [ const validFor = [
{ hours: 24, name: t('day', {count: 1}) }, { hours: 24, name: t("day", { count: 1 }) },
{ hours: 48, name: t('day', {count: 2}) }, { hours: 48, name: t("day", { count: 2 }) },
{ hours: 72, name: t('day', {count: 3}) }, { hours: 72, name: t("day", { count: 3 }) },
{ hours: 96, name: t('day', {count: 4}) }, { hours: 96, name: t("day", { count: 4 }) },
{ hours: 120, name: t('day', {count: 5}) }, { hours: 120, name: t("day", { count: 5 }) },
{ hours: 144, name: t('day', {count: 6}) }, { hours: 144, name: t("day", { count: 6 }) },
{ hours: 168, name: t('day', {count: 7}) } { hours: 168, name: t("day", { count: 7 }) }
]; ];
const internalForm = useForm<z.infer<typeof internalFormSchema>>({ const internalForm = useForm<z.infer<typeof internalFormSchema>>({
@ -157,10 +159,10 @@ export default function Page() {
console.error(e); console.error(e);
toast({ toast({
variant: "destructive", variant: "destructive",
title: t('accessRoleErrorFetch'), title: t("accessRoleErrorFetch"),
description: formatAxiosError( description: formatAxiosError(
e, e,
t('accessRoleErrorFetchDescription') t("accessRoleErrorFetchDescription")
) )
}); });
}); });
@ -180,10 +182,10 @@ export default function Page() {
console.error(e); console.error(e);
toast({ toast({
variant: "destructive", variant: "destructive",
title: t('idpErrorFetch'), title: t("idpErrorFetch"),
description: formatAxiosError( description: formatAxiosError(
e, e,
t('idpErrorFetchDescription') t("idpErrorFetchDescription")
) )
}); });
}); });
@ -220,16 +222,16 @@ export default function Page() {
if (e.response?.status === 409) { if (e.response?.status === 409) {
toast({ toast({
variant: "destructive", variant: "destructive",
title: t('userErrorExists'), title: t("userErrorExists"),
description: t('userErrorExistsDescription') description: t("userErrorExistsDescription")
}); });
} else { } else {
toast({ toast({
variant: "destructive", variant: "destructive",
title: t('inviteError'), title: t("inviteError"),
description: formatAxiosError( description: formatAxiosError(
e, e,
t('inviteErrorDescription') t("inviteErrorDescription")
) )
}); });
} }
@ -239,8 +241,8 @@ export default function Page() {
setInviteLink(res.data.data.inviteLink); setInviteLink(res.data.data.inviteLink);
toast({ toast({
variant: "default", variant: "default",
title: t('userInvited'), title: t("userInvited"),
description: t('userInvitedDescription') description: t("userInvitedDescription")
}); });
setExpiresInDays(parseInt(values.validForHours) / 24); setExpiresInDays(parseInt(values.validForHours) / 24);
@ -266,10 +268,10 @@ export default function Page() {
.catch((e) => { .catch((e) => {
toast({ toast({
variant: "destructive", variant: "destructive",
title: t('userErrorCreate'), title: t("userErrorCreate"),
description: formatAxiosError( description: formatAxiosError(
e, e,
t('userErrorCreateDescription') t("userErrorCreateDescription")
) )
}); });
}); });
@ -277,8 +279,8 @@ export default function Page() {
if (res && res.status === 201) { if (res && res.status === 201) {
toast({ toast({
variant: "default", variant: "default",
title: t('userCreated'), title: t("userCreated"),
description: t('userCreatedDescription') description: t("userCreatedDescription")
}); });
router.push(`/${orgId}/settings/access/users`); router.push(`/${orgId}/settings/access/users`);
} }
@ -289,13 +291,13 @@ export default function Page() {
const userTypes: ReadonlyArray<UserTypeOption> = [ const userTypes: ReadonlyArray<UserTypeOption> = [
{ {
id: "internal", id: "internal",
title: t('userTypeInternal'), title: t("userTypeInternal"),
description: t('userTypeInternalDescription') description: t("userTypeInternalDescription")
}, },
{ {
id: "oidc", id: "oidc",
title: t('userTypeExternal'), title: t("userTypeExternal"),
description: t('userTypeExternalDescription') description: t("userTypeExternalDescription")
} }
]; ];
@ -303,8 +305,8 @@ export default function Page() {
<> <>
<div className="flex justify-between"> <div className="flex justify-between">
<HeaderTitle <HeaderTitle
title={t('accessUserCreate')} title={t("accessUserCreate")}
description={t('accessUserCreateDescription')} description={t("accessUserCreateDescription")}
/> />
<Button <Button
variant="outline" variant="outline"
@ -312,219 +314,239 @@ export default function Page() {
router.push(`/${orgId}/settings/access/users`); router.push(`/${orgId}/settings/access/users`);
}} }}
> >
{t('userSeeAll')} {t("userSeeAll")}
</Button> </Button>
</div> </div>
<div> <div>
<SettingsContainer> <SettingsContainer>
<SettingsSection> {!inviteLink && (
<SettingsSectionHeader> <SettingsSection>
<SettingsSectionTitle> <SettingsSectionHeader>
{t('userTypeTitle')} <SettingsSectionTitle>
</SettingsSectionTitle> {t("userTypeTitle")}
<SettingsSectionDescription> </SettingsSectionTitle>
{t('userTypeDescription')} <SettingsSectionDescription>
</SettingsSectionDescription> {t("userTypeDescription")}
</SettingsSectionHeader> </SettingsSectionDescription>
<SettingsSectionBody> </SettingsSectionHeader>
<StrategySelect <SettingsSectionBody>
options={userTypes} <StrategySelect
defaultValue={userType || undefined} options={userTypes}
onChange={(value) => { defaultValue={userType || undefined}
setUserType(value as UserType); onChange={(value) => {
if (value === "internal") { setUserType(value as UserType);
internalForm.reset(); if (value === "internal") {
} else if (value === "oidc") { internalForm.reset();
externalForm.reset(); } else if (value === "oidc") {
setSelectedIdp(null); externalForm.reset();
} setSelectedIdp(null);
}} }
cols={2} }}
/> cols={2}
</SettingsSectionBody> />
</SettingsSection> </SettingsSectionBody>
</SettingsSection>
)}
{userType === "internal" && dataLoaded && ( {userType === "internal" && dataLoaded && (
<> <>
<SettingsSection> {!inviteLink ? (
<SettingsSectionHeader> <SettingsSection>
<SettingsSectionTitle> <SettingsSectionHeader>
{t('userSettings')} <SettingsSectionTitle>
</SettingsSectionTitle> {t("userSettings")}
<SettingsSectionDescription> </SettingsSectionTitle>
{t('userSettingsDescription')} <SettingsSectionDescription>
</SettingsSectionDescription> {t("userSettingsDescription")}
</SettingsSectionHeader> </SettingsSectionDescription>
<SettingsSectionBody> </SettingsSectionHeader>
<SettingsSectionForm> <SettingsSectionBody>
<Form {...internalForm}> <SettingsSectionForm>
<form <Form {...internalForm}>
onSubmit={internalForm.handleSubmit( <form
onSubmitInternal onSubmit={internalForm.handleSubmit(
)} onSubmitInternal
className="space-y-4"
id="create-user-form"
>
<FormField
control={
internalForm.control
}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('email')}
</FormLabel>
<FormControl>
<Input
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)} )}
/> className="space-y-4"
id="create-user-form"
{env.email.emailEnabled && ( >
<div className="flex items-center space-x-2"> <FormField
<Checkbox control={
id="send-email" internalForm.control
checked={sendEmail} }
onCheckedChange={( name="email"
e render={({ field }) => (
) => <FormItem>
setSendEmail( <FormLabel>
e as boolean {t("email")}
) </FormLabel>
}
/>
<label
htmlFor="send-email"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{t('inviteEmailSent')}
</label>
</div>
)}
<FormField
control={
internalForm.control
}
name="validForHours"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('inviteValid')}
</FormLabel>
<Select
onValueChange={
field.onChange
}
defaultValue={
field.value
}
>
<FormControl> <FormControl>
<SelectTrigger> <Input
<SelectValue placeholder={t('selectDuration')} /> {...field}
</SelectTrigger> />
</FormControl> </FormControl>
<SelectContent> <FormMessage />
{validFor.map( </FormItem>
( )}
option />
) => (
<SelectItem
key={
option.hours
}
value={option.hours.toString()}
>
{
option.name
}
</SelectItem>
)
)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField <FormField
control={ control={
internalForm.control internalForm.control
} }
name="roleId" name="validForHours"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel> <FormLabel>
{t('role')} {t(
</FormLabel> "inviteValid"
<Select )}
onValueChange={ </FormLabel>
field.onChange <Select
onValueChange={
field.onChange
}
defaultValue={
field.value
}
>
<FormControl>
<SelectTrigger className="w-full">
<SelectValue
placeholder={t(
"selectDuration"
)}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
{validFor.map(
(
option
) => (
<SelectItem
key={
option.hours
}
value={option.hours.toString()}
>
{
option.name
}
</SelectItem>
)
)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={
internalForm.control
}
name="roleId"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("role")}
</FormLabel>
<Select
onValueChange={
field.onChange
}
>
<FormControl>
<SelectTrigger className="w-full">
<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>
)}
/>
{env.email.emailEnabled && (
<div className="flex items-center space-x-2">
<Checkbox
id="send-email"
checked={sendEmail}
onCheckedChange={(
e
) =>
setSendEmail(
e as boolean
)
} }
/>
<label
htmlFor="send-email"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
> >
<FormControl> {t(
<SelectTrigger> "inviteEmailSent"
<SelectValue placeholder={t('accessRoleSelect')} /> )}
</SelectTrigger> </label>
</FormControl> </div>
<SelectContent>
{roles.map(
(
role
) => (
<SelectItem
key={
role.roleId
}
value={role.roleId.toString()}
>
{
role.name
}
</SelectItem>
)
)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)} )}
/> </form>
</Form>
{inviteLink && ( </SettingsSectionForm>
<div className="max-w-md space-y-4"> </SettingsSectionBody>
{sendEmail && ( </SettingsSection>
<p> ) : (
{t('inviteEmailSentDescription')} <SettingsSection>
</p> <SettingsSectionHeader>
)} <SettingsSectionTitle>
{!sendEmail && ( {t("userInvited")}
<p> </SettingsSectionTitle>
{t('inviteSentDescription')} <SettingsSectionDescription>
</p> {sendEmail
)} ? t("inviteEmailSentDescription")
<p> : t("inviteSentDescription")}
{t('inviteExpiresIn', {days: expiresInDays})} </SettingsSectionDescription>
</p> </SettingsSectionHeader>
<CopyTextBox <SettingsSectionBody>
text={inviteLink} <div className="space-y-4">
wrapText={false} <p>
/> {t("inviteExpiresIn", {
</div> days: expiresInDays
)} })}
</form> </p>
</Form> <CopyTextBox
</SettingsSectionForm> text={inviteLink}
</SettingsSectionBody> wrapText={false}
</SettingsSection> />
</div>
</SettingsSectionBody>
</SettingsSection>
)}
</> </>
)} )}
@ -533,16 +555,16 @@ export default function Page() {
<SettingsSection> <SettingsSection>
<SettingsSectionHeader> <SettingsSectionHeader>
<SettingsSectionTitle> <SettingsSectionTitle>
{t('idpTitle')} {t("idpTitle")}
</SettingsSectionTitle> </SettingsSectionTitle>
<SettingsSectionDescription> <SettingsSectionDescription>
{t('idpSelect')} {t("idpSelect")}
</SettingsSectionDescription> </SettingsSectionDescription>
</SettingsSectionHeader> </SettingsSectionHeader>
<SettingsSectionBody> <SettingsSectionBody>
{idps.length === 0 ? ( {idps.length === 0 ? (
<p className="text-muted-foreground"> <p className="text-muted-foreground">
{t('idpNotConfigured')} {t("idpNotConfigured")}
</p> </p>
) : ( ) : (
<Form {...externalForm}> <Form {...externalForm}>
@ -596,10 +618,10 @@ export default function Page() {
<SettingsSection> <SettingsSection>
<SettingsSectionHeader> <SettingsSectionHeader>
<SettingsSectionTitle> <SettingsSectionTitle>
{t('userSettings')} {t("userSettings")}
</SettingsSectionTitle> </SettingsSectionTitle>
<SettingsSectionDescription> <SettingsSectionDescription>
{t('userSettingsDescription')} {t("userSettingsDescription")}
</SettingsSectionDescription> </SettingsSectionDescription>
</SettingsSectionHeader> </SettingsSectionHeader>
<SettingsSectionBody> <SettingsSectionBody>
@ -620,7 +642,9 @@ export default function Page() {
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel> <FormLabel>
{t('username')} {t(
"username"
)}
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input <Input
@ -628,7 +652,9 @@ export default function Page() {
/> />
</FormControl> </FormControl>
<p className="text-sm text-muted-foreground mt-1"> <p className="text-sm text-muted-foreground mt-1">
{t('usernameUniq')} {t(
"usernameUniq"
)}
</p> </p>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@ -643,7 +669,9 @@ export default function Page() {
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel> <FormLabel>
{t('emailOptional')} {t(
"emailOptional"
)}
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input <Input
@ -663,7 +691,9 @@ export default function Page() {
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel> <FormLabel>
{t('nameOptional')} {t(
"nameOptional"
)}
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input <Input
@ -683,7 +713,7 @@ export default function Page() {
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel> <FormLabel>
{t('role')} {t("role")}
</FormLabel> </FormLabel>
<Select <Select
onValueChange={ onValueChange={
@ -691,8 +721,12 @@ export default function Page() {
} }
> >
<FormControl> <FormControl>
<SelectTrigger> <SelectTrigger className="w-full">
<SelectValue placeholder={t('accessRoleSelect')} /> <SelectValue
placeholder={t(
"accessRoleSelect"
)}
/>
</SelectTrigger> </SelectTrigger>
</FormControl> </FormControl>
<SelectContent> <SelectContent>
@ -736,19 +770,17 @@ export default function Page() {
router.push(`/${orgId}/settings/access/users`); router.push(`/${orgId}/settings/access/users`);
}} }}
> >
{t('cancel')} {t("cancel")}
</Button> </Button>
{userType && dataLoaded && ( {userType && dataLoaded && (
<Button <Button
type="submit" type={inviteLink ? "button" : "submit"}
form="create-user-form" form={inviteLink ? undefined : "create-user-form"}
loading={loading} loading={loading}
disabled={ disabled={loading}
loading || onClick={inviteLink ? () => router.push(`/${orgId}/settings/access/users`) : undefined}
(userType === "internal" && inviteLink !== null)
}
> >
{t('accessUserCreate')} {inviteLink ? t("done") : t("accessUserCreate")}
</Button> </Button>
)} )}
</div> </div>

View file

@ -50,11 +50,14 @@ export default function OrgApiKeysTable({
const deleteSite = (apiKeyId: string) => { const deleteSite = (apiKeyId: string) => {
api.delete(`/org/${orgId}/api-key/${apiKeyId}`) api.delete(`/org/${orgId}/api-key/${apiKeyId}`)
.catch((e) => { .catch((e) => {
console.error(t('apiKeysErrorDelete'), e); console.error(t("apiKeysErrorDelete"), e);
toast({ toast({
variant: "destructive", variant: "destructive",
title: t('apiKeysErrorDelete'), title: t("apiKeysErrorDelete"),
description: formatAxiosError(e, t('apiKeysErrorDeleteMessage')) description: formatAxiosError(
e,
t("apiKeysErrorDeleteMessage")
)
}); });
}) })
.then(() => { .then(() => {
@ -68,41 +71,6 @@ export default function OrgApiKeysTable({
}; };
const columns: ColumnDef<OrgApiKeyRow>[] = [ const columns: ColumnDef<OrgApiKeyRow>[] = [
{
id: "dots",
cell: ({ row }) => {
const apiKeyROw = row.original;
const router = useRouter();
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={() => {
setSelected(apiKeyROw);
}}
>
<span>{t('viewSettings')}</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setSelected(apiKeyROw);
setIsDeleteModalOpen(true);
}}
>
<span className="text-red-500">{t('delete')}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
},
{ {
accessorKey: "name", accessorKey: "name",
header: ({ column }) => { header: ({ column }) => {
@ -113,7 +81,7 @@ export default function OrgApiKeysTable({
column.toggleSorting(column.getIsSorted() === "asc") column.toggleSorting(column.getIsSorted() === "asc")
} }
> >
{t('name')} {t("name")}
<ArrowUpDown className="ml-2 h-4 w-4" /> <ArrowUpDown className="ml-2 h-4 w-4" />
</Button> </Button>
); );
@ -121,7 +89,7 @@ export default function OrgApiKeysTable({
}, },
{ {
accessorKey: "key", accessorKey: "key",
header: t('key'), header: t("key"),
cell: ({ row }) => { cell: ({ row }) => {
const r = row.original; const r = row.original;
return <span className="font-mono">{r.key}</span>; return <span className="font-mono">{r.key}</span>;
@ -129,10 +97,10 @@ export default function OrgApiKeysTable({
}, },
{ {
accessorKey: "createdAt", accessorKey: "createdAt",
header: t('createdAt'), header: t("createdAt"),
cell: ({ row }) => { cell: ({ row }) => {
const r = row.original; const r = row.original;
return <span>{moment(r.createdAt).format("lll")} </span>; return <span>{moment(r.createdAt).format("lll")}</span>;
} }
}, },
{ {
@ -141,9 +109,43 @@ export default function OrgApiKeysTable({
const r = row.original; const r = row.original;
return ( return (
<div className="flex items-center justify-end"> <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}`}> <Link href={`/${orgId}/settings/api-keys/${r.id}`}>
<Button variant={"outlinePrimary"} className="ml-2"> <Button
{t('edit')} variant={"secondary"}
className="ml-2"
size="sm"
>
{t("edit")}
<ArrowRight className="ml-2 w-4 h-4" /> <ArrowRight className="ml-2 w-4 h-4" />
</Button> </Button>
</Link> </Link>
@ -165,24 +167,23 @@ export default function OrgApiKeysTable({
dialog={ dialog={
<div className="space-y-4"> <div className="space-y-4">
<p> <p>
{t('apiKeysQuestionRemove', {selectedApiKey: selected?.name || selected?.id})} {t("apiKeysQuestionRemove", {
selectedApiKey:
selected?.name || selected?.id
})}
</p> </p>
<p> <p>
<b> <b>{t("apiKeysMessageRemove")}</b>
{t('apiKeysMessageRemove')}
</b>
</p> </p>
<p> <p>{t("apiKeysMessageConfirm")}</p>
{t('apiKeysMessageConfirm')}
</p>
</div> </div>
} }
buttonText={t('apiKeysDeleteConfirm')} buttonText={t("apiKeysDeleteConfirm")}
onConfirm={async () => deleteSite(selected!.id)} onConfirm={async () => deleteSite(selected!.id)}
string={selected.name} string={selected.name}
title={t('apiKeysDelete')} title={t("apiKeysDelete")}
/> />
)} )}

View file

@ -2,16 +2,7 @@ import { internal } from "@app/lib/api";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { authCookieHeader } from "@app/lib/api/cookies"; import { authCookieHeader } from "@app/lib/api/cookies";
import { SidebarSettings } from "@app/components/SidebarSettings";
import Link from "next/link";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator
} from "@app/components/ui/breadcrumb";
import { GetApiKeyResponse } from "@server/routers/apiKeys"; import { GetApiKeyResponse } from "@server/routers/apiKeys";
import ApiKeyProvider from "@app/providers/ApiKeyProvider"; import ApiKeyProvider from "@app/providers/ApiKeyProvider";
import { HorizontalTabs } from "@app/components/HorizontalTabs"; import { HorizontalTabs } from "@app/components/HorizontalTabs";

View file

@ -25,21 +25,12 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { Input } from "@app/components/ui/input"; import { Input } from "@app/components/ui/input";
import { InfoIcon } from "lucide-react"; import { InfoIcon } from "lucide-react";
import { Button } from "@app/components/ui/button"; import { Button } from "@app/components/ui/button";
import { Checkbox, CheckboxWithLabel } from "@app/components/ui/checkbox";
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { createApiClient, formatAxiosError } from "@app/lib/api"; import { createApiClient, formatAxiosError } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast"; import { toast } from "@app/hooks/useToast";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator
} from "@app/components/ui/breadcrumb";
import Link from "next/link";
import { import {
CreateOrgApiKeyBody, CreateOrgApiKeyBody,
CreateOrgApiKeyResponse CreateOrgApiKeyResponse
@ -110,7 +101,7 @@ export default function Page() {
const copiedForm = useForm<CopiedFormValues>({ const copiedForm = useForm<CopiedFormValues>({
resolver: zodResolver(copiedFormSchema), resolver: zodResolver(copiedFormSchema),
defaultValues: { defaultValues: {
copied: false copied: true
} }
}); });
@ -308,54 +299,54 @@ export default function Page() {
</AlertDescription> </AlertDescription>
</Alert> </Alert>
<h4 className="font-semibold"> {/* <h4 className="font-semibold"> */}
{t('apiKeysInfo')} {/* {t('apiKeysInfo')} */}
</h4> {/* </h4> */}
<CopyTextBox <CopyTextBox
text={`${apiKey.apiKeyId}.${apiKey.apiKey}`} text={`${apiKey.apiKeyId}.${apiKey.apiKey}`}
/> />
<Form {...copiedForm}> {/* <Form {...copiedForm}> */}
<form {/* <form */}
className="space-y-4" {/* className="space-y-4" */}
id="copied-form" {/* id="copied-form" */}
> {/* > */}
<FormField {/* <FormField */}
control={copiedForm.control} {/* control={copiedForm.control} */}
name="copied" {/* name="copied" */}
render={({ field }) => ( {/* render={({ field }) => ( */}
<FormItem> {/* <FormItem> */}
<div className="flex items-center space-x-2"> {/* <div className="flex items-center space-x-2"> */}
<Checkbox {/* <Checkbox */}
id="terms" {/* id="terms" */}
defaultChecked={ {/* defaultChecked={ */}
copiedForm.getValues( {/* copiedForm.getValues( */}
"copied" {/* "copied" */}
) as boolean {/* ) as boolean */}
} {/* } */}
onCheckedChange={( {/* onCheckedChange={( */}
e {/* e */}
) => { {/* ) => { */}
copiedForm.setValue( {/* copiedForm.setValue( */}
"copied", {/* "copied", */}
e as boolean {/* e as boolean */}
); {/* ); */}
}} {/* }} */}
/> {/* /> */}
<label {/* <label */}
htmlFor="terms" {/* htmlFor="terms" */}
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" {/* className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" */}
> {/* > */}
{t('apiKeysConfirmCopy')} {/* {t('apiKeysConfirmCopy')} */}
</label> {/* </label> */}
</div> {/* </div> */}
<FormMessage /> {/* <FormMessage /> */}
</FormItem> {/* </FormItem> */}
)} {/* )} */}
/> {/* /> */}
</form> {/* </form> */}
</Form> {/* </Form> */}
</SettingsSectionBody> </SettingsSectionBody>
</SettingsSection> </SettingsSection>
)} )}

View file

@ -246,7 +246,7 @@ export default function ClientsTable({ clients, orgId }: ClientTableProps) {
<Link <Link
href={`/${clientRow.orgId}/settings/clients/${clientRow.id}`} href={`/${clientRow.orgId}/settings/clients/${clientRow.id}`}
> >
<Button variant={"outlinePrimary"} className="ml-2"> <Button variant={"secondary"} className="ml-2">
Edit Edit
<ArrowRight className="ml-2 w-4 h-4" /> <ArrowRight className="ml-2 w-4 h-4" />
</Button> </Button>

View file

@ -1,7 +1,6 @@
import { internal } from "@app/lib/api"; import { internal } from "@app/lib/api";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { authCookieHeader } from "@app/lib/api/cookies"; import { authCookieHeader } from "@app/lib/api/cookies";
import { SidebarSettings } from "@app/components/SidebarSettings";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { GetClientResponse } from "@server/routers/client"; import { GetClientResponse } from "@server/routers/client";
import ClientInfoCard from "./ClientInfoCard"; import ClientInfoCard from "./ClientInfoCard";

View file

@ -18,9 +18,9 @@ import { GetOrgUserResponse } from "@server/routers/user";
import UserProvider from "@app/providers/UserProvider"; import UserProvider from "@app/providers/UserProvider";
import { Layout } from "@app/components/Layout"; import { Layout } from "@app/components/Layout";
import { SidebarNavItem, SidebarNavProps } from "@app/components/SidebarNav"; import { SidebarNavItem, SidebarNavProps } from "@app/components/SidebarNav";
import { orgNavItems } from "@app/app/navigation";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { pullEnv } from "@app/lib/pullEnv"; import { pullEnv } from "@app/lib/pullEnv";
import { orgNavSections } from "@app/app/navigation";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@ -82,24 +82,9 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
} }
} catch (e) {} } catch (e) {}
if (env.flags.enableClients) {
const existing = orgNavItems.find(
(item) => item.title === "sidebarClients"
);
if (!existing) {
const clientsNavItem = {
title: "sidebarClients",
href: "/{orgId}/settings/clients",
icon: <Workflow className="h-4 w-4" />
};
orgNavItems.splice(1, 0, clientsNavItem);
}
}
return ( return (
<UserProvider user={user}> <UserProvider user={user}>
<Layout orgId={params.orgId} orgs={orgs} navItems={orgNavItems}> <Layout orgId={params.orgId} orgs={orgs} navItems={orgNavSections}>
{children} {children}
</Layout> </Layout>
</UserProvider> </UserProvider>

View file

@ -31,7 +31,7 @@ import CopyToClipboard from "@app/components/CopyToClipboard";
import { Switch } from "@app/components/ui/switch"; import { Switch } from "@app/components/ui/switch";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { UpdateResourceResponse } from "@server/routers/resource"; import { UpdateResourceResponse } from "@server/routers/resource";
import { useTranslations } from 'next-intl'; import { useTranslations } from "next-intl";
export type ResourceRow = { export type ResourceRow = {
id: number; id: number;
@ -65,11 +65,11 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
const deleteResource = (resourceId: number) => { const deleteResource = (resourceId: number) => {
api.delete(`/resource/${resourceId}`) api.delete(`/resource/${resourceId}`)
.catch((e) => { .catch((e) => {
console.error(t('resourceErrorDelte'), e); console.error(t("resourceErrorDelte"), e);
toast({ toast({
variant: "destructive", variant: "destructive",
title: t('resourceErrorDelte'), title: t("resourceErrorDelte"),
description: formatAxiosError(e, t('resourceErrorDelte')) description: formatAxiosError(e, t("resourceErrorDelte"))
}); });
}) })
.then(() => { .then(() => {
@ -89,50 +89,16 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
.catch((e) => { .catch((e) => {
toast({ toast({
variant: "destructive", variant: "destructive",
title: t('resourcesErrorUpdate'), title: t("resourcesErrorUpdate"),
description: formatAxiosError(e, t('resourcesErrorUpdateDescription')) description: formatAxiosError(
e,
t("resourcesErrorUpdateDescription")
)
}); });
}); });
} }
const columns: ColumnDef<ResourceRow>[] = [ const columns: ColumnDef<ResourceRow>[] = [
{
accessorKey: "dots",
header: "",
cell: ({ row }) => {
const resourceRow = row.original;
const router = useRouter();
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">
<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>
);
}
},
{ {
accessorKey: "name", accessorKey: "name",
header: ({ column }) => { header: ({ column }) => {
@ -143,7 +109,7 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
column.toggleSorting(column.getIsSorted() === "asc") column.toggleSorting(column.getIsSorted() === "asc")
} }
> >
{t('name')} {t("name")}
<ArrowUpDown className="ml-2 h-4 w-4" /> <ArrowUpDown className="ml-2 h-4 w-4" />
</Button> </Button>
); );
@ -159,7 +125,7 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
column.toggleSorting(column.getIsSorted() === "asc") column.toggleSorting(column.getIsSorted() === "asc")
} }
> >
{t('site')} {t("site")}
<ArrowUpDown className="ml-2 h-4 w-4" /> <ArrowUpDown className="ml-2 h-4 w-4" />
</Button> </Button>
); );
@ -170,7 +136,7 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
<Link <Link
href={`/${resourceRow.orgId}/settings/sites/${resourceRow.siteId}`} href={`/${resourceRow.orgId}/settings/sites/${resourceRow.siteId}`}
> >
<Button variant="outline"> <Button variant="outline" size="sm">
{resourceRow.site} {resourceRow.site}
<ArrowUpRight className="ml-2 h-4 w-4" /> <ArrowUpRight className="ml-2 h-4 w-4" />
</Button> </Button>
@ -180,7 +146,7 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
}, },
{ {
accessorKey: "protocol", accessorKey: "protocol",
header: t('protocol'), header: t("protocol"),
cell: ({ row }) => { cell: ({ row }) => {
const resourceRow = row.original; const resourceRow = row.original;
return <span>{resourceRow.protocol.toUpperCase()}</span>; return <span>{resourceRow.protocol.toUpperCase()}</span>;
@ -188,7 +154,7 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
}, },
{ {
accessorKey: "domain", accessorKey: "domain",
header: t('access'), header: t("access"),
cell: ({ row }) => { cell: ({ row }) => {
const resourceRow = row.original; const resourceRow = row.original;
return ( return (
@ -218,7 +184,7 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
column.toggleSorting(column.getIsSorted() === "asc") column.toggleSorting(column.getIsSorted() === "asc")
} }
> >
{t('authentication')} {t("authentication")}
<ArrowUpDown className="ml-2 h-4 w-4" /> <ArrowUpDown className="ml-2 h-4 w-4" />
</Button> </Button>
); );
@ -230,12 +196,12 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
{resourceRow.authState === "protected" ? ( {resourceRow.authState === "protected" ? (
<span className="text-green-500 flex items-center space-x-2"> <span className="text-green-500 flex items-center space-x-2">
<ShieldCheck className="w-4 h-4" /> <ShieldCheck className="w-4 h-4" />
<span>{t('protected')}</span> <span>{t("protected")}</span>
</span> </span>
) : resourceRow.authState === "not_protected" ? ( ) : resourceRow.authState === "not_protected" ? (
<span className="text-yellow-500 flex items-center space-x-2"> <span className="text-yellow-500 flex items-center space-x-2">
<ShieldOff className="w-4 h-4" /> <ShieldOff className="w-4 h-4" />
<span>{t('notProtected')}</span> <span>{t("notProtected")}</span>
</span> </span>
) : ( ) : (
<span>-</span> <span>-</span>
@ -246,7 +212,7 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
}, },
{ {
accessorKey: "enabled", accessorKey: "enabled",
header: t('enabled'), header: t("enabled"),
cell: ({ row }) => ( cell: ({ row }) => (
<Switch <Switch
defaultChecked={row.original.enabled} defaultChecked={row.original.enabled}
@ -262,11 +228,41 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
const resourceRow = row.original; const resourceRow = row.original;
return ( return (
<div className="flex items-center justify-end"> <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 <Link
href={`/${resourceRow.orgId}/settings/resources/${resourceRow.id}`} href={`/${resourceRow.orgId}/settings/resources/${resourceRow.id}`}
> >
<Button variant={"outlinePrimary"} className="ml-2"> <Button variant={"secondary"} className="ml-2" size="sm">
{t('edit')} {t("edit")}
<ArrowRight className="ml-2 w-4 h-4" /> <ArrowRight className="ml-2 w-4 h-4" />
</Button> </Button>
</Link> </Link>
@ -288,22 +284,22 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
dialog={ dialog={
<div> <div>
<p className="mb-2"> <p className="mb-2">
{t('resourceQuestionRemove', {selectedResource: selectedResource?.name || selectedResource?.id})} {t("resourceQuestionRemove", {
selectedResource:
selectedResource?.name ||
selectedResource?.id
})}
</p> </p>
<p className="mb-2"> <p className="mb-2">{t("resourceMessageRemove")}</p>
{t('resourceMessageRemove')}
</p>
<p> <p>{t("resourceMessageConfirm")}</p>
{t('resourceMessageConfirm')}
</p>
</div> </div>
} }
buttonText={t('resourceDeleteConfirm')} buttonText={t("resourceDeleteConfirm")}
onConfirm={async () => deleteResource(selectedResource!.id)} onConfirm={async () => deleteResource(selectedResource!.id)}
string={selectedResource.name} string={selectedResource.name}
title={t('resourceDelete')} title={t("resourceDelete")}
/> />
)} )}

View file

@ -568,7 +568,7 @@ export default function ResourceAuthenticationPage() {
</span> </span>
</div> </div>
<Button <Button
variant="outlinePrimary" variant="secondary"
onClick={ onClick={
authInfo.password authInfo.password
? removeResourcePassword ? removeResourcePassword
@ -593,7 +593,7 @@ export default function ResourceAuthenticationPage() {
</span> </span>
</div> </div>
<Button <Button
variant="outlinePrimary" variant="secondary"
onClick={ onClick={
authInfo.pincode authInfo.pincode
? removeResourcePincode ? removeResourcePincode

View file

@ -128,7 +128,7 @@ export default function ReverseProxyTargets(props: {
return true; return true;
}, },
{ {
message: t('proxyErrorInvalidHeader') message: t("proxyErrorInvalidHeader")
} }
) )
}); });
@ -146,7 +146,7 @@ export default function ReverseProxyTargets(props: {
return true; return true;
}, },
{ {
message: t('proxyErrorTls') message: t("proxyErrorTls")
} }
) )
}); });
@ -203,10 +203,10 @@ export default function ReverseProxyTargets(props: {
console.error(err); console.error(err);
toast({ toast({
variant: "destructive", variant: "destructive",
title: t('targetErrorFetch'), title: t("targetErrorFetch"),
description: formatAxiosError( description: formatAxiosError(
err, err,
t('targetErrorFetchDescription') t("targetErrorFetchDescription")
) )
}); });
} finally { } finally {
@ -228,10 +228,10 @@ export default function ReverseProxyTargets(props: {
console.error(err); console.error(err);
toast({ toast({
variant: "destructive", variant: "destructive",
title: t('siteErrorFetch'), title: t("siteErrorFetch"),
description: formatAxiosError( description: formatAxiosError(
err, err,
t('siteErrorFetchDescription') t("siteErrorFetchDescription")
) )
}); });
} }
@ -251,8 +251,8 @@ export default function ReverseProxyTargets(props: {
if (isDuplicate) { if (isDuplicate) {
toast({ toast({
variant: "destructive", variant: "destructive",
title: t('targetErrorDuplicate'), title: t("targetErrorDuplicate"),
description: t('targetErrorDuplicateDescription') description: t("targetErrorDuplicateDescription")
}); });
return; return;
} }
@ -264,8 +264,8 @@ export default function ReverseProxyTargets(props: {
if (!isIPInSubnet(targetIp, subnet)) { if (!isIPInSubnet(targetIp, subnet)) {
toast({ toast({
variant: "destructive", variant: "destructive",
title: t('targetWireGuardErrorInvalidIp'), title: t("targetWireGuardErrorInvalidIp"),
description: t('targetWireGuardErrorInvalidIpDescription') description: t("targetWireGuardErrorInvalidIpDescription")
}); });
return; return;
} }
@ -307,10 +307,13 @@ export default function ReverseProxyTargets(props: {
); );
} }
async function saveTargets() { async function saveAllSettings() {
try { try {
setTargetsLoading(true); setTargetsLoading(true);
setHttpsTlsLoading(true);
setProxySettingsLoading(true);
// Save targets
for (let target of targets) { for (let target of targets) {
const data = { const data = {
ip: target.ip, ip: target.ip,
@ -342,9 +345,31 @@ export default function ReverseProxyTargets(props: {
}); });
updateResource({ stickySession: stickySessionData.stickySession }); updateResource({ stickySession: stickySessionData.stickySession });
// Save TLS settings
const tlsData = tlsSettingsForm.getValues();
await api.post(`/resource/${params.resourceId}`, {
ssl: tlsData.ssl,
tlsServerName: tlsData.tlsServerName || null
});
updateResource({
...resource,
ssl: tlsData.ssl,
tlsServerName: tlsData.tlsServerName || null
});
// Save proxy settings
const proxyData = proxySettingsForm.getValues();
await api.post(`/resource/${params.resourceId}`, {
setHostHeader: proxyData.setHostHeader || null
});
updateResource({
...resource,
setHostHeader: proxyData.setHostHeader || null
});
toast({ toast({
title: t('targetsUpdated'), title: t("settingsUpdated"),
description: t('targetsUpdatedDescription') description: t("settingsUpdatedDescription")
}); });
setTargetsToRemove([]); setTargetsToRemove([]);
@ -353,73 +378,15 @@ export default function ReverseProxyTargets(props: {
console.error(err); console.error(err);
toast({ toast({
variant: "destructive", variant: "destructive",
title: t('targetsErrorUpdate'), title: t("settingsErrorUpdate"),
description: formatAxiosError( description: formatAxiosError(
err, err,
t('targetsErrorUpdateDescription') t("settingsErrorUpdateDescription")
) )
}); });
} finally { } finally {
setTargetsLoading(false); setTargetsLoading(false);
}
}
async function saveTlsSettings(data: TlsSettingsValues) {
try {
setHttpsTlsLoading(true);
await api.post(`/resource/${params.resourceId}`, {
ssl: data.ssl,
tlsServerName: data.tlsServerName || null
});
updateResource({
...resource,
ssl: data.ssl,
tlsServerName: data.tlsServerName || null
});
toast({
title: t('targetTlsUpdate'),
description: t('targetTlsUpdateDescription')
});
} catch (err) {
console.error(err);
toast({
variant: "destructive",
title: t('targetErrorTlsUpdate'),
description: formatAxiosError(
err,
t('targetErrorTlsUpdateDescription')
)
});
} finally {
setHttpsTlsLoading(false); setHttpsTlsLoading(false);
}
}
async function saveProxySettings(data: ProxySettingsValues) {
try {
setProxySettingsLoading(true);
await api.post(`/resource/${params.resourceId}`, {
setHostHeader: data.setHostHeader || null
});
updateResource({
...resource,
setHostHeader: data.setHostHeader || null
});
toast({
title: t('proxyUpdated'),
description: t('proxyUpdatedDescription')
});
} catch (err) {
console.error(err);
toast({
variant: "destructive",
title: t('proxyErrorUpdate'),
description: formatAxiosError(
err,
t('proxyErrorUpdateDescription')
)
});
} finally {
setProxySettingsLoading(false); setProxySettingsLoading(false);
} }
} }
@ -427,7 +394,7 @@ export default function ReverseProxyTargets(props: {
const columns: ColumnDef<LocalTarget>[] = [ const columns: ColumnDef<LocalTarget>[] = [
{ {
accessorKey: "ip", accessorKey: "ip",
header: t('targetAddr'), header: t("targetAddr"),
cell: ({ row }) => ( cell: ({ row }) => (
<Input <Input
defaultValue={row.original.ip} defaultValue={row.original.ip}
@ -442,7 +409,7 @@ export default function ReverseProxyTargets(props: {
}, },
{ {
accessorKey: "port", accessorKey: "port",
header: t('targetPort'), header: t("targetPort"),
cell: ({ row }) => ( cell: ({ row }) => (
<Input <Input
type="number" type="number"
@ -476,7 +443,7 @@ export default function ReverseProxyTargets(props: {
// }, // },
{ {
accessorKey: "enabled", accessorKey: "enabled",
header: t('enabled'), header: t("enabled"),
cell: ({ row }) => ( cell: ({ row }) => (
<Switch <Switch
defaultChecked={row.original.enabled} defaultChecked={row.original.enabled}
@ -503,7 +470,7 @@ export default function ReverseProxyTargets(props: {
variant="outline" variant="outline"
onClick={() => removeTarget(row.original.targetId)} onClick={() => removeTarget(row.original.targetId)}
> >
{t('delete')} {t("delete")}
</Button> </Button>
</div> </div>
</> </>
@ -514,7 +481,7 @@ export default function ReverseProxyTargets(props: {
if (resource.http) { if (resource.http) {
const methodCol: ColumnDef<LocalTarget> = { const methodCol: ColumnDef<LocalTarget> = {
accessorKey: "method", accessorKey: "method",
header: t('method'), header: t("method"),
cell: ({ row }) => ( cell: ({ row }) => (
<Select <Select
defaultValue={row.original.method ?? ""} defaultValue={row.original.method ?? ""}
@ -561,11 +528,9 @@ export default function ReverseProxyTargets(props: {
<SettingsContainer> <SettingsContainer>
<SettingsSection> <SettingsSection>
<SettingsSectionHeader> <SettingsSectionHeader>
<SettingsSectionTitle> <SettingsSectionTitle>{t("targets")}</SettingsSectionTitle>
{t('targets')}
</SettingsSectionTitle>
<SettingsSectionDescription> <SettingsSectionDescription>
{t('targetsDescription')} {t("targetsDescription")}
</SettingsSectionDescription> </SettingsSectionDescription>
</SettingsSectionHeader> </SettingsSectionHeader>
<SettingsSectionBody> <SettingsSectionBody>
@ -573,7 +538,7 @@ export default function ReverseProxyTargets(props: {
<Form {...targetsSettingsForm}> <Form {...targetsSettingsForm}>
<form <form
onSubmit={targetsSettingsForm.handleSubmit( onSubmit={targetsSettingsForm.handleSubmit(
saveTargets saveAllSettings
)} )}
className="space-y-4" className="space-y-4"
id="targets-settings-form" id="targets-settings-form"
@ -587,8 +552,12 @@ export default function ReverseProxyTargets(props: {
<FormControl> <FormControl>
<SwitchInput <SwitchInput
id="sticky-toggle" id="sticky-toggle"
label={t('targetStickySessions')} label={t(
description={t('targetStickySessionsDescription')} "targetStickySessions"
)}
description={t(
"targetStickySessionsDescription"
)}
defaultChecked={ defaultChecked={
field.value field.value
} }
@ -619,7 +588,9 @@ export default function ReverseProxyTargets(props: {
name="method" name="method"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>{t('method')}</FormLabel> <FormLabel>
{t("method")}
</FormLabel>
<FormControl> <FormControl>
<Select <Select
value={ value={
@ -635,8 +606,15 @@ export default function ReverseProxyTargets(props: {
); );
}} }}
> >
<SelectTrigger id="method"> <SelectTrigger
<SelectValue placeholder={t('methodSelect')} /> id="method"
className="w-full"
>
<SelectValue
placeholder={t(
"methodSelect"
)}
/>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="http"> <SelectItem value="http">
@ -662,7 +640,9 @@ export default function ReverseProxyTargets(props: {
name="ip" name="ip"
render={({ field }) => ( render={({ field }) => (
<FormItem className="relative"> <FormItem className="relative">
<FormLabel>{t('targetAddr')}</FormLabel> <FormLabel>
{t("targetAddr")}
</FormLabel>
<FormControl> <FormControl>
<Input id="ip" {...field} /> <Input id="ip" {...field} />
</FormControl> </FormControl>
@ -695,7 +675,9 @@ export default function ReverseProxyTargets(props: {
name="port" name="port"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>{t('targetPort')}</FormLabel> <FormLabel>
{t("targetPort")}
</FormLabel>
<FormControl> <FormControl>
<Input <Input
id="port" id="port"
@ -710,11 +692,11 @@ export default function ReverseProxyTargets(props: {
/> />
<Button <Button
type="submit" type="submit"
variant="outlinePrimary" variant="secondary"
className="mt-6" className="mt-6"
disabled={!(watchedIp && watchedPort)} disabled={!(watchedIp && watchedPort)}
> >
{t('targetSubmit')} {t("targetSubmit")}
</Button> </Button>
</div> </div>
</form> </form>
@ -758,189 +740,170 @@ export default function ReverseProxyTargets(props: {
colSpan={columns.length} colSpan={columns.length}
className="h-24 text-center" className="h-24 text-center"
> >
{t('targetNoOne')} {t("targetNoOne")}
</TableCell> </TableCell>
</TableRow> </TableRow>
)} )}
</TableBody> </TableBody>
<TableCaption> {/* <TableCaption> */}
{t('targetNoOneDescription')} {/* {t('targetNoOneDescription')} */}
</TableCaption> {/* </TableCaption> */}
</Table> </Table>
</SettingsSectionBody> </SettingsSectionBody>
<SettingsSectionFooter>
<Button
onClick={saveTargets}
loading={targetsLoading}
disabled={targetsLoading}
form="targets-settings-form"
>
{t('targetsSubmit')}
</Button>
</SettingsSectionFooter>
</SettingsSection> </SettingsSection>
{resource.http && ( {resource.http && (
<SettingsSectionGrid cols={2}> <SettingsSection>
<SettingsSection> <SettingsSectionHeader>
<SettingsSectionHeader> <SettingsSectionTitle>
<SettingsSectionTitle> {t("proxyAdditional")}
{t('targetTlsSettings')} </SettingsSectionTitle>
</SettingsSectionTitle> <SettingsSectionDescription>
<SettingsSectionDescription> {t("proxyAdditionalDescription")}
{t('targetTlsSettingsDescription')} </SettingsSectionDescription>
</SettingsSectionDescription> </SettingsSectionHeader>
</SettingsSectionHeader> <SettingsSectionBody>
<SettingsSectionBody> <SettingsSectionForm>
<SettingsSectionForm> <Form {...tlsSettingsForm}>
<Form {...tlsSettingsForm}> <form
<form onSubmit={tlsSettingsForm.handleSubmit(
onSubmit={tlsSettingsForm.handleSubmit( saveAllSettings
saveTlsSettings )}
className="space-y-4"
id="tls-settings-form"
>
<FormField
control={tlsSettingsForm.control}
name="ssl"
render={({ field }) => (
<FormItem>
<FormControl>
<SwitchInput
id="ssl-toggle"
label={t(
"proxyEnableSSL"
)}
defaultChecked={
field.value
}
onCheckedChange={(
val
) => {
field.onChange(val);
}}
/>
</FormControl>
</FormItem>
)} )}
className="space-y-4" />
id="tls-settings-form" <Collapsible
open={isAdvancedOpen}
onOpenChange={setIsAdvancedOpen}
className="space-y-2"
> >
<FormField <div className="flex items-center justify-between space-x-4">
control={tlsSettingsForm.control} <CollapsibleTrigger asChild>
name="ssl" <Button
render={({ field }) => ( variant="text"
<FormItem> size="sm"
<FormControl> className="p-0 flex items-center justify-start gap-2 w-full"
<SwitchInput >
id="ssl-toggle" <p className="text-sm text-muted-foreground">
label={t('proxyEnableSSL')} {t(
defaultChecked={ "targetTlsSettingsAdvanced"
field.value )}
} </p>
onCheckedChange={( <div>
val <ChevronsUpDown className="h-4 w-4" />
) => { <span className="sr-only">
field.onChange( Toggle
val </span>
); </div>
}} </Button>
/> </CollapsibleTrigger>
</FormControl> </div>
</FormItem> <CollapsibleContent className="space-y-2">
)} <FormField
/> control={
<Collapsible tlsSettingsForm.control
open={isAdvancedOpen} }
onOpenChange={setIsAdvancedOpen} name="tlsServerName"
className="space-y-2" render={({ field }) => (
> <FormItem>
<div className="flex items-center justify-between space-x-4"> <FormLabel>
<CollapsibleTrigger asChild> {t("targetTlsSni")}
<Button </FormLabel>
variant="text" <FormControl>
size="sm" <Input {...field} />
className="p-0 flex items-center justify-start gap-2 w-full" </FormControl>
> <FormDescription>
<p className="text-sm text-muted-foreground"> {t(
{t('targetTlsSettingsAdvanced')} "targetTlsSniDescription"
</p> )}
<div> </FormDescription>
<ChevronsUpDown className="h-4 w-4" /> <FormMessage />
<span className="sr-only"> </FormItem>
Toggle )}
</span> />
</div> </CollapsibleContent>
</Button> </Collapsible>
</CollapsibleTrigger> </form>
</div> </Form>
<CollapsibleContent className="space-y-2"> </SettingsSectionForm>
<FormField
control={ <SettingsSectionForm>
tlsSettingsForm.control <Form {...proxySettingsForm}>
} <form
name="tlsServerName" onSubmit={proxySettingsForm.handleSubmit(
render={({ field }) => ( saveAllSettings
<FormItem> )}
<FormLabel> className="space-y-4"
{t('targetTlsSni')} id="proxy-settings-form"
</FormLabel> >
<FormControl> <FormField
<Input control={proxySettingsForm.control}
{...field} name="setHostHeader"
/> render={({ field }) => (
</FormControl> <FormItem>
<FormDescription> <FormLabel>
{t('targetTlsSniDescription')} {t("proxyCustomHeader")}
</FormDescription> </FormLabel>
<FormMessage /> <FormControl>
</FormItem> <Input {...field} />
</FormControl>
<FormDescription>
{t(
"proxyCustomHeaderDescription"
)} )}
/> </FormDescription>
</CollapsibleContent> <FormMessage />
</Collapsible> </FormItem>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
type="submit"
loading={httpsTlsLoading}
form="tls-settings-form"
>
{t('targetTlsSubmit')}
</Button>
</SettingsSectionFooter>
</SettingsSection>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t('proxyAdditional')}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t('proxyAdditionalDescription')}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...proxySettingsForm}>
<form
onSubmit={proxySettingsForm.handleSubmit(
saveProxySettings
)} )}
className="space-y-4" />
id="proxy-settings-form" </form>
> </Form>
<FormField </SettingsSectionForm>
control={proxySettingsForm.control} </SettingsSectionBody>
name="setHostHeader" </SettingsSection>
render={({ field }) => (
<FormItem>
<FormLabel>
{t('proxyCustomHeader')}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{t('proxyCustomHeaderDescription')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
type="submit"
loading={proxySettingsLoading}
form="proxy-settings-form"
>
{t('targetTlsSubmit')}
</Button>
</SettingsSectionFooter>
</SettingsSection>
</SettingsSectionGrid>
)} )}
<div className="flex justify-end mt-6">
<Button
onClick={saveAllSettings}
loading={
targetsLoading ||
httpsTlsLoading ||
proxySettingsLoading
}
disabled={
targetsLoading ||
httpsTlsLoading ||
proxySettingsLoading
}
>
{t("saveAllSettings")}
</Button>
</div>
</SettingsContainer> </SettingsContainer>
); );
} }
@ -953,7 +916,7 @@ function isIPInSubnet(subnet: string, ip: string): boolean {
const t = useTranslations(); const t = useTranslations();
if (mask < 0 || mask > 32) { if (mask < 0 || mask > 32) {
throw new Error(t('subnetMaskErrorInvalid')); throw new Error(t("subnetMaskErrorInvalid"));
} }
// Convert IP addresses to binary numbers // Convert IP addresses to binary numbers
@ -973,14 +936,14 @@ function ipToNumber(ip: string): number {
const t = useTranslations(); const t = useTranslations();
if (parts.length !== 4) { if (parts.length !== 4) {
throw new Error(t('ipAddressErrorInvalidFormat')); throw new Error(t("ipAddressErrorInvalidFormat"));
} }
// Convert IP octets to 32-bit number // Convert IP octets to 32-bit number
return parts.reduce((num, octet) => { return parts.reduce((num, octet) => {
const oct = parseInt(octet); const oct = parseInt(octet);
if (isNaN(oct) || oct < 0 || oct > 255) { if (isNaN(oct) || oct < 0 || oct > 255) {
throw new Error(t('ipAddressErrorInvalidOctet')); throw new Error(t("ipAddressErrorInvalidOctet"));
} }
return (num << 8) + oct; return (num << 8) + oct;
}, 0); }, 0);

View file

@ -36,7 +36,6 @@ import {
TableBody, TableBody,
TableCaption, TableCaption,
TableCell, TableCell,
TableContainer,
TableHead, TableHead,
TableHeader, TableHeader,
TableRow TableRow
@ -543,48 +542,48 @@ export default function ResourceRules(props: {
return ( return (
<SettingsContainer> <SettingsContainer>
<Alert className="hidden md:block"> {/* <Alert className="hidden md:block"> */}
<InfoIcon className="h-4 w-4" /> {/* <InfoIcon className="h-4 w-4" /> */}
<AlertTitle className="font-semibold">{t('rulesAbout')}</AlertTitle> {/* <AlertTitle className="font-semibold">{t('rulesAbout')}</AlertTitle> */}
<AlertDescription className="mt-4"> {/* <AlertDescription className="mt-4"> */}
<div className="space-y-1 mb-4"> {/* <div className="space-y-1 mb-4"> */}
<p> {/* <p> */}
{t('rulesAboutDescription')} {/* {t('rulesAboutDescription')} */}
</p> {/* </p> */}
</div> {/* </div> */}
<InfoSections cols={2}> {/* <InfoSections cols={2}> */}
<InfoSection> {/* <InfoSection> */}
<InfoSectionTitle>{t('rulesActions')}</InfoSectionTitle> {/* <InfoSectionTitle>{t('rulesActions')}</InfoSectionTitle> */}
<ul className="text-sm text-muted-foreground space-y-1"> {/* <ul className="text-sm text-muted-foreground space-y-1"> */}
<li className="flex items-center gap-2"> {/* <li className="flex items-center gap-2"> */}
<Check className="text-green-500 w-4 h-4" /> {/* <Check className="text-green-500 w-4 h-4" /> */}
{t('rulesActionAlwaysAllow')} {/* {t('rulesActionAlwaysAllow')} */}
</li> {/* </li> */}
<li className="flex items-center gap-2"> {/* <li className="flex items-center gap-2"> */}
<X className="text-red-500 w-4 h-4" /> {/* <X className="text-red-500 w-4 h-4" /> */}
{t('rulesActionAlwaysDeny')} {/* {t('rulesActionAlwaysDeny')} */}
</li> {/* </li> */}
</ul> {/* </ul> */}
</InfoSection> {/* </InfoSection> */}
<InfoSection> {/* <InfoSection> */}
<InfoSectionTitle> {/* <InfoSectionTitle> */}
{t('rulesMatchCriteria')} {/* {t('rulesMatchCriteria')} */}
</InfoSectionTitle> {/* </InfoSectionTitle> */}
<ul className="text-sm text-muted-foreground space-y-1"> {/* <ul className="text-sm text-muted-foreground space-y-1"> */}
<li className="flex items-center gap-2"> {/* <li className="flex items-center gap-2"> */}
{t('rulesMatchCriteriaIpAddress')} {/* {t('rulesMatchCriteriaIpAddress')} */}
</li> {/* </li> */}
<li className="flex items-center gap-2"> {/* <li className="flex items-center gap-2"> */}
{t('rulesMatchCriteriaIpAddressRange')} {/* {t('rulesMatchCriteriaIpAddressRange')} */}
</li> {/* </li> */}
<li className="flex items-center gap-2"> {/* <li className="flex items-center gap-2"> */}
{t('rulesMatchCriteriaUrl')} {/* {t('rulesMatchCriteriaUrl')} */}
</li> {/* </li> */}
</ul> {/* </ul> */}
</InfoSection> {/* </InfoSection> */}
</InfoSections> {/* </InfoSections> */}
</AlertDescription> {/* </AlertDescription> */}
</Alert> {/* </Alert> */}
<SettingsSection> <SettingsSection>
<SettingsSectionHeader> <SettingsSectionHeader>
@ -634,7 +633,7 @@ export default function ResourceRules(props: {
field.onChange field.onChange
} }
> >
<SelectTrigger> <SelectTrigger className="w-full">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -664,7 +663,7 @@ export default function ResourceRules(props: {
field.onChange field.onChange
} }
> >
<SelectTrigger> <SelectTrigger className="w-full">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -690,7 +689,7 @@ export default function ResourceRules(props: {
control={addRuleForm.control} control={addRuleForm.control}
name="value" name="value"
render={({ field }) => ( render={({ field }) => (
<FormItem className="space-y-0 mb-2"> <FormItem className="gap-1">
<InfoPopup <InfoPopup
text={t('value')} text={t('value')}
info={ info={
@ -702,7 +701,7 @@ export default function ResourceRules(props: {
} }
/> />
<FormControl> <FormControl>
<Input {...field} /> <Input {...field}/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@ -710,8 +709,7 @@ export default function ResourceRules(props: {
/> />
<Button <Button
type="submit" type="submit"
variant="outlinePrimary" variant="secondary"
className="mb-2"
disabled={!rulesEnabled} disabled={!rulesEnabled}
> >
{t('ruleSubmit')} {t('ruleSubmit')}
@ -762,9 +760,9 @@ export default function ResourceRules(props: {
</TableRow> </TableRow>
)} )}
</TableBody> </TableBody>
<TableCaption> {/* <TableCaption> */}
{t('rulesOrder')} {/* {t('rulesOrder')} */}
</TableCaption> {/* </TableCaption> */}
</Table> </Table>
</SettingsSectionBody> </SettingsSectionBody>
<SettingsSectionFooter> <SettingsSectionFooter>

View file

@ -459,29 +459,31 @@ export default function Page() {
</SettingsSectionBody> </SettingsSectionBody>
</SettingsSection> </SettingsSection>
<SettingsSection> {resourceTypes.length > 1 && (
<SettingsSectionHeader> <SettingsSection>
<SettingsSectionTitle> <SettingsSectionHeader>
{t("resourceType")} <SettingsSectionTitle>
</SettingsSectionTitle> {t("resourceType")}
<SettingsSectionDescription> </SettingsSectionTitle>
{t("resourceTypeDescription")} <SettingsSectionDescription>
</SettingsSectionDescription> {t("resourceTypeDescription")}
</SettingsSectionHeader> </SettingsSectionDescription>
<SettingsSectionBody> </SettingsSectionHeader>
<StrategySelect <SettingsSectionBody>
options={resourceTypes} <StrategySelect
defaultValue="http" options={resourceTypes}
onChange={(value) => { defaultValue="http"
baseForm.setValue( onChange={(value) => {
"http", baseForm.setValue(
value === "http" "http",
); value === "http"
}} );
cols={2} }}
/> cols={2}
</SettingsSectionBody> />
</SettingsSection> </SettingsSectionBody>
</SettingsSection>
)}
{baseForm.watch("http") ? ( {baseForm.watch("http") ? (
<SettingsSection> <SettingsSection>

View file

@ -392,7 +392,7 @@ export default function CreateShareLinkForm({
defaultValue={field.value.toString()} defaultValue={field.value.toString()}
> >
<FormControl> <FormControl>
<SelectTrigger> <SelectTrigger className="w-full">
<SelectValue placeholder={t('selectDuration')} /> <SelectValue placeholder={t('selectDuration')} />
</SelectTrigger> </SelectTrigger>
</FormControl> </FormControl>

View file

@ -69,11 +69,8 @@ export default function ShareLinksTable({
async function deleteSharelink(id: string) { async function deleteSharelink(id: string) {
await api.delete(`/access-token/${id}`).catch((e) => { await api.delete(`/access-token/${id}`).catch((e) => {
toast({ toast({
title: t('shareErrorDelete'), title: t("shareErrorDelete"),
description: formatAxiosError( description: formatAxiosError(e, t("shareErrorDeleteMessage"))
e,
t('shareErrorDeleteMessage')
)
}); });
}); });
@ -81,53 +78,12 @@ export default function ShareLinksTable({
setRows(newRows); setRows(newRows);
toast({ toast({
title: t('shareDeleted'), title: t("shareDeleted"),
description: t('shareDeletedDescription') description: t("shareDeletedDescription")
}); });
} }
const columns: ColumnDef<ShareLinkRow>[] = [ const columns: ColumnDef<ShareLinkRow>[] = [
{
id: "actions",
cell: ({ row }) => {
const router = useRouter();
const resourceRow = row.original;
return (
<>
<div>
<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>
</div>
</>
);
}
},
{ {
accessorKey: "resourceName", accessorKey: "resourceName",
header: ({ column }) => { header: ({ column }) => {
@ -138,7 +94,7 @@ export default function ShareLinksTable({
column.toggleSorting(column.getIsSorted() === "asc") column.toggleSorting(column.getIsSorted() === "asc")
} }
> >
{t('resource')} {t("resource")}
<ArrowUpDown className="ml-2 h-4 w-4" /> <ArrowUpDown className="ml-2 h-4 w-4" />
</Button> </Button>
); );
@ -147,7 +103,7 @@ export default function ShareLinksTable({
const r = row.original; const r = row.original;
return ( return (
<Link href={`/${orgId}/settings/resources/${r.resourceId}`}> <Link href={`/${orgId}/settings/resources/${r.resourceId}`}>
<Button variant="outline"> <Button variant="outline" size="sm">
{r.resourceName}{" "} {r.resourceName}{" "}
{r.siteName ? `(${r.siteName})` : ""} {r.siteName ? `(${r.siteName})` : ""}
<ArrowUpRight className="ml-2 h-4 w-4" /> <ArrowUpRight className="ml-2 h-4 w-4" />
@ -166,7 +122,7 @@ export default function ShareLinksTable({
column.toggleSorting(column.getIsSorted() === "asc") column.toggleSorting(column.getIsSorted() === "asc")
} }
> >
{t('title')} {t("title")}
<ArrowUpDown className="ml-2 h-4 w-4" /> <ArrowUpDown className="ml-2 h-4 w-4" />
</Button> </Button>
); );
@ -245,7 +201,7 @@ export default function ShareLinksTable({
column.toggleSorting(column.getIsSorted() === "asc") column.toggleSorting(column.getIsSorted() === "asc")
} }
> >
{t('created')} {t("created")}
<ArrowUpDown className="ml-2 h-4 w-4" /> <ArrowUpDown className="ml-2 h-4 w-4" />
</Button> </Button>
); );
@ -265,7 +221,7 @@ export default function ShareLinksTable({
column.toggleSorting(column.getIsSorted() === "asc") column.toggleSorting(column.getIsSorted() === "asc")
} }
> >
{t('expires')} {t("expires")}
<ArrowUpDown className="ml-2 h-4 w-4" /> <ArrowUpDown className="ml-2 h-4 w-4" />
</Button> </Button>
); );
@ -275,23 +231,50 @@ export default function ShareLinksTable({
if (r.expiresAt) { if (r.expiresAt) {
return moment(r.expiresAt).format("lll"); return moment(r.expiresAt).format("lll");
} }
return t('never'); return t("never");
} }
}, },
{ {
id: "delete", id: "delete",
cell: ({ row }) => ( cell: ({ row }) => {
<div className="flex items-center justify-end space-x-2"> const resourceRow = row.original;
<Button return (
variant="outlinePrimary" <div className="flex items-center justify-end space-x-2">
onClick={() => {/* <DropdownMenu> */}
deleteSharelink(row.original.accessTokenId) {/* <DropdownMenuTrigger asChild> */}
} {/* <Button variant="ghost" className="h-8 w-8 p-0"> */}
> {/* <span className="sr-only"> */}
{t('delete')} {/* {t("openMenu")} */}
</Button> {/* </span> */}
</div> {/* <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>
);
}
} }
]; ];

View file

@ -28,7 +28,7 @@ import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import CreateSiteFormModal from "./CreateSiteModal"; import CreateSiteFormModal from "./CreateSiteModal";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { parseDataSize } from '@app/lib/dataSize'; import { parseDataSize } from "@app/lib/dataSize";
export type SiteRow = { export type SiteRow = {
id: number; id: number;
@ -81,11 +81,11 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
const deleteSite = (siteId: number) => { const deleteSite = (siteId: number) => {
api.delete(`/site/${siteId}`) api.delete(`/site/${siteId}`)
.catch((e) => { .catch((e) => {
console.error(t('siteErrorDelete'), e); console.error(t("siteErrorDelete"), e);
toast({ toast({
variant: "destructive", variant: "destructive",
title: t('siteErrorDelete'), title: t("siteErrorDelete"),
description: formatAxiosError(e, t('siteErrorDelete')) description: formatAxiosError(e, t("siteErrorDelete"))
}); });
}) })
.then(() => { .then(() => {
@ -99,42 +99,6 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
}; };
const columns: ColumnDef<SiteRow>[] = [ const columns: ColumnDef<SiteRow>[] = [
{
id: "dots",
cell: ({ row }) => {
const siteRow = row.original;
const router = useRouter();
return (
<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>
);
}
},
{ {
accessorKey: "name", accessorKey: "name",
header: ({ column }) => { header: ({ column }) => {
@ -145,7 +109,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
column.toggleSorting(column.getIsSorted() === "asc") column.toggleSorting(column.getIsSorted() === "asc")
} }
> >
{t('name')} {t("name")}
<ArrowUpDown className="ml-2 h-4 w-4" /> <ArrowUpDown className="ml-2 h-4 w-4" />
</Button> </Button>
); );
@ -161,7 +125,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
column.toggleSorting(column.getIsSorted() === "asc") column.toggleSorting(column.getIsSorted() === "asc")
} }
> >
{t('online')} {t("online")}
<ArrowUpDown className="ml-2 h-4 w-4" /> <ArrowUpDown className="ml-2 h-4 w-4" />
</Button> </Button>
); );
@ -176,14 +140,14 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
return ( return (
<span className="text-green-500 flex items-center space-x-2"> <span className="text-green-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-green-500 rounded-full"></div> <div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span>{t('online')}</span> <span>{t("online")}</span>
</span> </span>
); );
} else { } else {
return ( return (
<span className="text-neutral-500 flex items-center space-x-2"> <span className="text-neutral-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-gray-500 rounded-full"></div> <div className="w-2 h-2 bg-gray-500 rounded-full"></div>
<span>{t('offline')}</span> <span>{t("offline")}</span>
</span> </span>
); );
} }
@ -203,7 +167,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
} }
className="hidden md:flex whitespace-nowrap" className="hidden md:flex whitespace-nowrap"
> >
{t('site')} {t("site")}
<ArrowUpDown className="ml-2 h-4 w-4" /> <ArrowUpDown className="ml-2 h-4 w-4" />
</Button> </Button>
); );
@ -226,13 +190,14 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
column.toggleSorting(column.getIsSorted() === "asc") column.toggleSorting(column.getIsSorted() === "asc")
} }
> >
{t('dataIn')} {t("dataIn")}
<ArrowUpDown className="ml-2 h-4 w-4" /> <ArrowUpDown className="ml-2 h-4 w-4" />
</Button> </Button>
); );
}, },
sortingFn: (rowA, rowB) => sortingFn: (rowA, rowB) =>
parseDataSize(rowA.original.mbIn) - parseDataSize(rowB.original.mbIn) parseDataSize(rowA.original.mbIn) -
parseDataSize(rowB.original.mbIn)
}, },
{ {
accessorKey: "mbOut", accessorKey: "mbOut",
@ -244,13 +209,14 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
column.toggleSorting(column.getIsSorted() === "asc") column.toggleSorting(column.getIsSorted() === "asc")
} }
> >
{t('dataOut')} {t("dataOut")}
<ArrowUpDown className="ml-2 h-4 w-4" /> <ArrowUpDown className="ml-2 h-4 w-4" />
</Button> </Button>
); );
}, },
sortingFn: (rowA, rowB) => sortingFn: (rowA, rowB) =>
parseDataSize(rowA.original.mbOut) - parseDataSize(rowB.original.mbOut), parseDataSize(rowA.original.mbOut) -
parseDataSize(rowB.original.mbOut)
}, },
{ {
accessorKey: "type", accessorKey: "type",
@ -262,7 +228,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
column.toggleSorting(column.getIsSorted() === "asc") column.toggleSorting(column.getIsSorted() === "asc")
} }
> >
{t('connectionType')} {t("connectionType")}
<ArrowUpDown className="ml-2 h-4 w-4" /> <ArrowUpDown className="ml-2 h-4 w-4" />
</Button> </Button>
); );
@ -289,7 +255,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
if (originalRow.type === "local") { if (originalRow.type === "local") {
return ( return (
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<span>{t('local')}</span> <span>{t("local")}</span>
</div> </div>
); );
} }
@ -316,12 +282,41 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
cell: ({ row }) => { cell: ({ row }) => {
const siteRow = row.original; const siteRow = row.original;
return ( return (
<div className="flex items-center justify-end"> <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 <Link
href={`/${siteRow.orgId}/settings/sites/${siteRow.nice}`} href={`/${siteRow.orgId}/settings/sites/${siteRow.nice}`}
> >
<Button variant={"outlinePrimary"} className="ml-2"> <Button variant={"secondary"} size="sm">
{t('edit')} {t("edit")}
<ArrowRight className="ml-2 w-4 h-4" /> <ArrowRight className="ml-2 w-4 h-4" />
</Button> </Button>
</Link> </Link>
@ -343,22 +338,21 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
dialog={ dialog={
<div className="space-y-4"> <div className="space-y-4">
<p> <p>
{t('siteQuestionRemove', {selectedSite: selectedSite?.name || selectedSite?.id})} {t("siteQuestionRemove", {
selectedSite:
selectedSite?.name || selectedSite?.id
})}
</p> </p>
<p> <p>{t("siteMessageRemove")}</p>
{t('siteMessageRemove')}
</p>
<p> <p>{t("siteMessageConfirm")}</p>
{t('siteMessageConfirm')}
</p>
</div> </div>
} }
buttonText={t('siteConfirmDelete')} buttonText={t("siteConfirmDelete")}
onConfirm={async () => deleteSite(selectedSite!.id)} onConfirm={async () => deleteSite(selectedSite!.id)}
string={selectedSite.name} string={selectedSite.name}
title={t('siteDelete')} title={t("siteDelete")}
/> />
)} )}

View file

@ -5,16 +5,7 @@ import { AxiosResponse } from "axios";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { authCookieHeader } from "@app/lib/api/cookies"; import { authCookieHeader } from "@app/lib/api/cookies";
import { HorizontalTabs } from "@app/components/HorizontalTabs"; import { HorizontalTabs } from "@app/components/HorizontalTabs";
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator
} from "@app/components/ui/breadcrumb";
import SiteInfoCard from "./SiteInfoCard"; import SiteInfoCard from "./SiteInfoCard";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";

View file

@ -55,14 +55,6 @@ import {
import { toast } from "@app/hooks/useToast"; import { toast } from "@app/hooks/useToast";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator
} from "@app/components/ui/breadcrumb";
import Link from "next/link";
import { QRCodeCanvas } from "qrcode.react"; import { QRCodeCanvas } from "qrcode.react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
@ -117,7 +109,8 @@ export default function Page() {
.refine( .refine(
(data) => { (data) => {
if (data.method !== "local") { if (data.method !== "local") {
return data.copied; // return data.copied;
return true;
} }
return true; return true;
}, },
@ -622,26 +615,28 @@ WantedBy=default.target`
</SettingsSectionBody> </SettingsSectionBody>
</SettingsSection> </SettingsSection>
<SettingsSection> {tunnelTypes.length > 1 && (
<SettingsSectionHeader> <SettingsSection>
<SettingsSectionTitle> <SettingsSectionHeader>
{t('tunnelType')} <SettingsSectionTitle>
</SettingsSectionTitle> {t('tunnelType')}
<SettingsSectionDescription> </SettingsSectionTitle>
{t('siteTunnelDescription')} <SettingsSectionDescription>
</SettingsSectionDescription> {t('siteTunnelDescription')}
</SettingsSectionHeader> </SettingsSectionDescription>
<SettingsSectionBody> </SettingsSectionHeader>
<StrategySelect <SettingsSectionBody>
options={tunnelTypes} <StrategySelect
defaultValue={form.getValues("method")} options={tunnelTypes}
onChange={(value) => { defaultValue={form.getValues("method")}
form.setValue("method", value); onChange={(value) => {
}} form.setValue("method", value);
cols={3} }}
/> cols={3}
</SettingsSectionBody> />
</SettingsSection> </SettingsSectionBody>
</SettingsSection>
)}
{form.watch("method") === "newt" && ( {form.watch("method") === "newt" && (
<> <>
@ -700,46 +695,46 @@ WantedBy=default.target`
</AlertDescription> </AlertDescription>
</Alert> </Alert>
<Form {...form}> {/* <Form {...form}> */}
<form {/* <form */}
className="space-y-4" {/* className="space-y-4" */}
id="create-site-form" {/* id="create-site-form" */}
> {/* > */}
<FormField {/* <FormField */}
control={form.control} {/* control={form.control} */}
name="copied" {/* name="copied" */}
render={({ field }) => ( {/* render={({ field }) => ( */}
<FormItem> {/* <FormItem> */}
<div className="flex items-center space-x-2"> {/* <div className="flex items-center space-x-2"> */}
<Checkbox {/* <Checkbox */}
id="terms" {/* id="terms" */}
defaultChecked={ {/* defaultChecked={ */}
form.getValues( {/* form.getValues( */}
"copied" {/* "copied" */}
) as boolean {/* ) as boolean */}
} {/* } */}
onCheckedChange={( {/* onCheckedChange={( */}
e {/* e */}
) => { {/* ) => { */}
form.setValue( {/* form.setValue( */}
"copied", {/* "copied", */}
e as boolean {/* e as boolean */}
); {/* ); */}
}} {/* }} */}
/> {/* /> */}
<label {/* <label */}
htmlFor="terms" {/* htmlFor="terms" */}
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" {/* className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" */}
> {/* > */}
{t('siteConfirmCopy')} {/* {t('siteConfirmCopy')} */}
</label> {/* </label> */}
</div> {/* </div> */}
<FormMessage /> {/* <FormMessage /> */}
</FormItem> {/* </FormItem> */}
)} {/* )} */}
/> {/* /> */}
</form> {/* </form> */}
</Form> {/* </Form> */}
</SettingsSectionBody> </SettingsSectionBody>
</SettingsSection> </SettingsSection>
<SettingsSection> <SettingsSection>

View file

@ -46,11 +46,14 @@ export default function ApiKeysTable({ apiKeys }: ApiKeyTableProps) {
const deleteSite = (apiKeyId: string) => { const deleteSite = (apiKeyId: string) => {
api.delete(`/api-key/${apiKeyId}`) api.delete(`/api-key/${apiKeyId}`)
.catch((e) => { .catch((e) => {
console.error(t('apiKeysErrorDelete'), e); console.error(t("apiKeysErrorDelete"), e);
toast({ toast({
variant: "destructive", variant: "destructive",
title: t('apiKeysErrorDelete'), title: t("apiKeysErrorDelete"),
description: formatAxiosError(e, t('apiKeysErrorDeleteMessage')) description: formatAxiosError(
e,
t("apiKeysErrorDeleteMessage")
)
}); });
}) })
.then(() => { .then(() => {
@ -64,41 +67,6 @@ export default function ApiKeysTable({ apiKeys }: ApiKeyTableProps) {
}; };
const columns: ColumnDef<ApiKeyRow>[] = [ const columns: ColumnDef<ApiKeyRow>[] = [
{
id: "dots",
cell: ({ row }) => {
const apiKeyROw = row.original;
const router = useRouter();
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={() => {
setSelected(apiKeyROw);
}}
>
<span>{t('viewSettings')}</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setSelected(apiKeyROw);
setIsDeleteModalOpen(true);
}}
>
<span className="text-red-500">{t('delete')}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
},
{ {
accessorKey: "name", accessorKey: "name",
header: ({ column }) => { header: ({ column }) => {
@ -109,7 +77,7 @@ export default function ApiKeysTable({ apiKeys }: ApiKeyTableProps) {
column.toggleSorting(column.getIsSorted() === "asc") column.toggleSorting(column.getIsSorted() === "asc")
} }
> >
{t('name')} {t("name")}
<ArrowUpDown className="ml-2 h-4 w-4" /> <ArrowUpDown className="ml-2 h-4 w-4" />
</Button> </Button>
); );
@ -117,7 +85,7 @@ export default function ApiKeysTable({ apiKeys }: ApiKeyTableProps) {
}, },
{ {
accessorKey: "key", accessorKey: "key",
header: t('key'), header: t("key"),
cell: ({ row }) => { cell: ({ row }) => {
const r = row.original; const r = row.original;
return <span className="font-mono">{r.key}</span>; return <span className="font-mono">{r.key}</span>;
@ -125,7 +93,7 @@ export default function ApiKeysTable({ apiKeys }: ApiKeyTableProps) {
}, },
{ {
accessorKey: "createdAt", accessorKey: "createdAt",
header: t('createdAt'), header: t("createdAt"),
cell: ({ row }) => { cell: ({ row }) => {
const r = row.original; const r = row.original;
return <span>{moment(r.createdAt).format("lll")} </span>; return <span>{moment(r.createdAt).format("lll")} </span>;
@ -136,13 +104,44 @@ export default function ApiKeysTable({ apiKeys }: ApiKeyTableProps) {
cell: ({ row }) => { cell: ({ row }) => {
const r = row.original; const r = row.original;
return ( return (
<div className="flex items-center justify-end"> <div className="flex items-center justify-end gap-2">
<Link href={`/admin/api-keys/${r.id}`}> <DropdownMenu>
<Button variant={"outlinePrimary"} className="ml-2"> <DropdownMenuTrigger asChild>
{t('edit')} <Button variant="ghost" className="h-8 w-8 p-0">
<ArrowRight className="ml-2 w-4 h-4" /> <span className="sr-only">
</Button> {t("openMenu")}
</Link> </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> </div>
); );
} }
@ -161,24 +160,23 @@ export default function ApiKeysTable({ apiKeys }: ApiKeyTableProps) {
dialog={ dialog={
<div className="space-y-4"> <div className="space-y-4">
<p> <p>
{t('apiKeysQuestionRemove', {selectedApiKey: selected?.name || selected?.id})} {t("apiKeysQuestionRemove", {
selectedApiKey:
selected?.name || selected?.id
})}
</p> </p>
<p> <p>
<b> <b>{t("apiKeysMessageRemove")}</b>
{t('apiKeysMessageRemove')}
</b>
</p> </p>
<p> <p>{t("apiKeysMessageConfirm")}</p>
{t('apiKeysMessageConfirm')}
</p>
</div> </div>
} }
buttonText={t('apiKeysDeleteConfirm')} buttonText={t("apiKeysDeleteConfirm")}
onConfirm={async () => deleteSite(selected!.id)} onConfirm={async () => deleteSite(selected!.id)}
string={selected.name} string={selected.name}
title={t('apiKeysDelete')} title={t("apiKeysDelete")}
/> />
)} )}

View file

@ -2,16 +2,7 @@ import { internal } from "@app/lib/api";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { authCookieHeader } from "@app/lib/api/cookies"; import { authCookieHeader } from "@app/lib/api/cookies";
import { SidebarSettings } from "@app/components/SidebarSettings";
import Link from "next/link";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator
} from "@app/components/ui/breadcrumb";
import { GetApiKeyResponse } from "@server/routers/apiKeys"; import { GetApiKeyResponse } from "@server/routers/apiKeys";
import ApiKeyProvider from "@app/providers/ApiKeyProvider"; import ApiKeyProvider from "@app/providers/ApiKeyProvider";
import { HorizontalTabs } from "@app/components/HorizontalTabs"; import { HorizontalTabs } from "@app/components/HorizontalTabs";

View file

@ -25,21 +25,12 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { Input } from "@app/components/ui/input"; import { Input } from "@app/components/ui/input";
import { InfoIcon } from "lucide-react"; import { InfoIcon } from "lucide-react";
import { Button } from "@app/components/ui/button"; import { Button } from "@app/components/ui/button";
import { Checkbox, CheckboxWithLabel } from "@app/components/ui/checkbox";
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { createApiClient, formatAxiosError } from "@app/lib/api"; import { createApiClient, formatAxiosError } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast"; import { toast } from "@app/hooks/useToast";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { useParams, useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator
} from "@app/components/ui/breadcrumb";
import Link from "next/link";
import { import {
CreateOrgApiKeyBody, CreateOrgApiKeyBody,
CreateOrgApiKeyResponse CreateOrgApiKeyResponse
@ -108,7 +99,7 @@ export default function Page() {
const copiedForm = useForm<CopiedFormValues>({ const copiedForm = useForm<CopiedFormValues>({
resolver: zodResolver(copiedFormSchema), resolver: zodResolver(copiedFormSchema),
defaultValues: { defaultValues: {
copied: false copied: true
} }
}); });
@ -299,54 +290,54 @@ export default function Page() {
</AlertDescription> </AlertDescription>
</Alert> </Alert>
<h4 className="font-semibold"> {/* <h4 className="font-semibold"> */}
{t('apiKeysInfo')} {/* {t('apiKeysInfo')} */}
</h4> {/* </h4> */}
<CopyTextBox <CopyTextBox
text={`${apiKey.apiKeyId}.${apiKey.apiKey}`} text={`${apiKey.apiKeyId}.${apiKey.apiKey}`}
/> />
<Form {...copiedForm}> {/* <Form {...copiedForm}> */}
<form {/* <form */}
className="space-y-4" {/* className="space-y-4" */}
id="copied-form" {/* id="copied-form" */}
> {/* > */}
<FormField {/* <FormField */}
control={copiedForm.control} {/* control={copiedForm.control} */}
name="copied" {/* name="copied" */}
render={({ field }) => ( {/* render={({ field }) => ( */}
<FormItem> {/* <FormItem> */}
<div className="flex items-center space-x-2"> {/* <div className="flex items-center space-x-2"> */}
<Checkbox {/* <Checkbox */}
id="terms" {/* id="terms" */}
defaultChecked={ {/* defaultChecked={ */}
copiedForm.getValues( {/* copiedForm.getValues( */}
"copied" {/* "copied" */}
) as boolean {/* ) as boolean */}
} {/* } */}
onCheckedChange={( {/* onCheckedChange={( */}
e {/* e */}
) => { {/* ) => { */}
copiedForm.setValue( {/* copiedForm.setValue( */}
"copied", {/* "copied", */}
e as boolean {/* e as boolean */}
); {/* ); */}
}} {/* }} */}
/> {/* /> */}
<label {/* <label */}
htmlFor="terms" {/* htmlFor="terms" */}
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" {/* className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" */}
> {/* > */}
{t('apiKeysConfirmCopy')} {/* {t('apiKeysConfirmCopy')} */}
</label> {/* </label> */}
</div> {/* </div> */}
<FormMessage /> {/* <FormMessage /> */}
</FormItem> {/* </FormItem> */}
)} {/* )} */}
/> {/* /> */}
</form> {/* </form> */}
</Form> {/* </Form> */}
</SettingsSectionBody> </SettingsSectionBody>
</SettingsSection> </SettingsSection>
)} )}

View file

@ -43,14 +43,14 @@ export default function IdpTable({ idps }: Props) {
try { try {
await api.delete(`/idp/${idpId}`); await api.delete(`/idp/${idpId}`);
toast({ toast({
title: t('success'), title: t("success"),
description: t('idpDeletedDescription') description: t("idpDeletedDescription")
}); });
setIsDeleteModalOpen(false); setIsDeleteModalOpen(false);
router.refresh(); router.refresh();
} catch (e) { } catch (e) {
toast({ toast({
title: t('error'), title: t("error"),
description: formatAxiosError(e), description: formatAxiosError(e),
variant: "destructive" variant: "destructive"
}); });
@ -67,41 +67,6 @@ export default function IdpTable({ idps }: Props) {
}; };
const columns: ColumnDef<IdpRow>[] = [ const columns: ColumnDef<IdpRow>[] = [
{
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">
<Link
className="block w-full"
href={`/admin/idp/${r.idpId}/general`}
>
<DropdownMenuItem>
{t('viewSettings')}
</DropdownMenuItem>
</Link>
<DropdownMenuItem
onClick={() => {
setSelectedIdp(r);
setIsDeleteModalOpen(true);
}}
>
<span className="text-red-500">{t('delete')}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
},
{ {
accessorKey: "idpId", accessorKey: "idpId",
header: ({ column }) => { header: ({ column }) => {
@ -128,7 +93,7 @@ export default function IdpTable({ idps }: Props) {
column.toggleSorting(column.getIsSorted() === "asc") column.toggleSorting(column.getIsSorted() === "asc")
} }
> >
{t('name')} {t("name")}
<ArrowUpDown className="ml-2 h-4 w-4" /> <ArrowUpDown className="ml-2 h-4 w-4" />
</Button> </Button>
); );
@ -144,7 +109,7 @@ export default function IdpTable({ idps }: Props) {
column.toggleSorting(column.getIsSorted() === "asc") column.toggleSorting(column.getIsSorted() === "asc")
} }
> >
{t('type')} {t("type")}
<ArrowUpDown className="ml-2 h-4 w-4" /> <ArrowUpDown className="ml-2 h-4 w-4" />
</Button> </Button>
); );
@ -162,9 +127,43 @@ export default function IdpTable({ idps }: Props) {
const siteRow = row.original; const siteRow = row.original;
return ( return (
<div className="flex items-center justify-end"> <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`}> <Link href={`/admin/idp/${siteRow.idpId}/general`}>
<Button variant={"outlinePrimary"} className="ml-2"> <Button
{t('edit')} variant={"secondary"}
className="ml-2"
size="sm"
>
{t("edit")}
<ArrowRight className="ml-2 w-4 h-4" /> <ArrowRight className="ml-2 w-4 h-4" />
</Button> </Button>
</Link> </Link>
@ -186,22 +185,20 @@ export default function IdpTable({ idps }: Props) {
dialog={ dialog={
<div className="space-y-4"> <div className="space-y-4">
<p> <p>
{t('idpQuestionRemove', {name: selectedIdp.name})} {t("idpQuestionRemove", {
name: selectedIdp.name
})}
</p> </p>
<p> <p>
<b> <b>{t("idpMessageRemove")}</b>
{t('idpMessageRemove')}
</b>
</p>
<p>
{t('idpMessageConfirm')}
</p> </p>
<p>{t("idpMessageConfirm")}</p>
</div> </div>
} }
buttonText={t('idpConfirmDelete')} buttonText={t("idpConfirmDelete")}
onConfirm={async () => deleteIdp(selectedIdp.idpId)} onConfirm={async () => deleteIdp(selectedIdp.idpId)}
string={selectedIdp.name} string={selectedIdp.name}
title={t('idpDelete')} title={t("idpDelete")}
/> />
)} )}

View file

@ -4,17 +4,7 @@ import { AxiosResponse } from "axios";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { authCookieHeader } from "@app/lib/api/cookies"; import { authCookieHeader } from "@app/lib/api/cookies";
import { HorizontalTabs } from "@app/components/HorizontalTabs"; import { HorizontalTabs } from "@app/components/HorizontalTabs";
import { ProfessionalContentOverlay } from "@app/components/ProfessionalContentOverlay";
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator
} from "@app/components/ui/breadcrumb";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
interface SettingsLayoutProps { interface SettingsLayoutProps {

View file

@ -140,7 +140,7 @@ export default function PolicyTable({ policies, onDelete, onAdd, onEdit }: Props
return ( return (
<div className="flex items-center justify-end"> <div className="flex items-center justify-end">
<Button <Button
variant={"outlinePrimary"} variant={"secondary"}
className="ml-2" className="ml-2"
onClick={() => onEdit(policy)} onClick={() => onEdit(policy)}
> >

View file

@ -9,7 +9,7 @@ import { internal } from "@app/lib/api";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { authCookieHeader } from "@app/lib/api/cookies"; import { authCookieHeader } from "@app/lib/api/cookies";
import { Layout } from "@app/components/Layout"; import { Layout } from "@app/components/Layout";
import { adminNavItems } from "../navigation"; import { adminNavSections } from "../navigation";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@ -47,7 +47,7 @@ export default async function AdminLayout(props: LayoutProps) {
return ( return (
<UserProvider user={user}> <UserProvider user={user}>
<Layout orgs={orgs} navItems={adminNavItems}> <Layout orgs={orgs} navItems={adminNavSections}>
{props.children} {props.children}
</Layout> </Layout>
</UserProvider> </UserProvider>

View file

@ -122,7 +122,7 @@ export function LicenseKeysDataTable({
cell: ({ row }) => ( cell: ({ row }) => (
<div className="flex items-center justify-end space-x-2"> <div className="flex items-center justify-end space-x-2">
<Button <Button
variant="outlinePrimary" variant="secondary"
onClick={() => onDelete(row.original)} onClick={() => onDelete(row.original)}
> >
{t('delete')} {t('delete')}

View file

@ -41,11 +41,11 @@ export default function UsersTable({ users }: Props) {
const deleteUser = (id: string) => { const deleteUser = (id: string) => {
api.delete(`/user/${id}`) api.delete(`/user/${id}`)
.catch((e) => { .catch((e) => {
console.error(t('userErrorDelete'), e); console.error(t("userErrorDelete"), e);
toast({ toast({
variant: "destructive", variant: "destructive",
title: t('userErrorDelete'), title: t("userErrorDelete"),
description: formatAxiosError(e, t('userErrorDelete')) description: formatAxiosError(e, t("userErrorDelete"))
}); });
}) })
.then(() => { .then(() => {
@ -84,7 +84,7 @@ export default function UsersTable({ users }: Props) {
column.toggleSorting(column.getIsSorted() === "asc") column.toggleSorting(column.getIsSorted() === "asc")
} }
> >
{t('username')} {t("username")}
<ArrowUpDown className="ml-2 h-4 w-4" /> <ArrowUpDown className="ml-2 h-4 w-4" />
</Button> </Button>
); );
@ -100,7 +100,7 @@ export default function UsersTable({ users }: Props) {
column.toggleSorting(column.getIsSorted() === "asc") column.toggleSorting(column.getIsSorted() === "asc")
} }
> >
{t('email')} {t("email")}
<ArrowUpDown className="ml-2 h-4 w-4" /> <ArrowUpDown className="ml-2 h-4 w-4" />
</Button> </Button>
); );
@ -116,7 +116,7 @@ export default function UsersTable({ users }: Props) {
column.toggleSorting(column.getIsSorted() === "asc") column.toggleSorting(column.getIsSorted() === "asc")
} }
> >
{t('name')} {t("name")}
<ArrowUpDown className="ml-2 h-4 w-4" /> <ArrowUpDown className="ml-2 h-4 w-4" />
</Button> </Button>
); );
@ -132,7 +132,7 @@ export default function UsersTable({ users }: Props) {
column.toggleSorting(column.getIsSorted() === "asc") column.toggleSorting(column.getIsSorted() === "asc")
} }
> >
{t('identityProvider')} {t("identityProvider")}
<ArrowUpDown className="ml-2 h-4 w-4" /> <ArrowUpDown className="ml-2 h-4 w-4" />
</Button> </Button>
); );
@ -146,14 +146,15 @@ export default function UsersTable({ users }: Props) {
<> <>
<div className="flex items-center justify-end"> <div className="flex items-center justify-end">
<Button <Button
variant={"outlinePrimary"} variant={"secondary"}
size="sm"
className="ml-2" className="ml-2"
onClick={() => { onClick={() => {
setSelected(r); setSelected(r);
setIsDeleteModalOpen(true); setIsDeleteModalOpen(true);
}} }}
> >
{t('delete')} {t("delete")}
</Button> </Button>
</div> </div>
</> </>
@ -174,26 +175,27 @@ export default function UsersTable({ users }: Props) {
dialog={ dialog={
<div className="space-y-4"> <div className="space-y-4">
<p> <p>
{t('userQuestionRemove', {selectedUser: selected?.email || selected?.name || selected?.username})} {t("userQuestionRemove", {
selectedUser:
selected?.email ||
selected?.name ||
selected?.username
})}
</p> </p>
<p> <p>
<b> <b>{t("userMessageRemove")}</b>
{t('userMessageRemove')}
</b>
</p> </p>
<p> <p>{t("userMessageConfirm")}</p>
{t('userMessageConfirm')}
</p>
</div> </div>
} }
buttonText={t('userDeleteConfirm')} buttonText={t("userDeleteConfirm")}
onConfirm={async () => deleteUser(selected!.id)} onConfirm={async () => deleteUser(selected!.id)}
string={ string={
selected.email || selected.name || selected.username selected.email || selected.name || selected.username
} }
title={t('userDeleteServer')} title={t("userDeleteServer")}
/> />
)} )}

View file

@ -1,125 +1,139 @@
@import url("https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=Space+Grotesk:wght@300..700&display=swap"); @import url("https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=Space+Grotesk:wght@300..700&display=swap");
@import 'tw-animate-css'; @import "tw-animate-css";
@import 'tailwindcss'; @import "tailwindcss";
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
:root { :root {
--background: hsl(0 0% 98%); --radius: 0.65rem;
--foreground: hsl(20 0% 10%); --background: oklch(0.99 0 0);
--card: hsl(0 0% 100%); --foreground: oklch(0.141 0.005 285.823);
--card-foreground: hsl(20 0% 10%); --card: oklch(1 0 0);
--popover: hsl(0 0% 100%); --card-foreground: oklch(0.141 0.005 285.823);
--popover-foreground: hsl(20 0% 10%); --popover: oklch(1 0 0);
--primary: hsl(24.6 95% 53.1%); --popover-foreground: oklch(0.141 0.005 285.823);
--primary-foreground: hsl(60 9.1% 97.8%); --primary: oklch(0.6717 0.1946 41.93);
--secondary: hsl(60 4.8% 95.9%); --primary-foreground: oklch(0.98 0.016 73.684);
--secondary-foreground: hsl(24 9.8% 10%); --secondary: oklch(0.967 0.001 286.375);
--muted: hsl(60 4.8% 85%); --secondary-foreground: oklch(0.21 0.006 285.885);
--muted-foreground: hsl(25 5.3% 44.7%); --muted: oklch(0.967 0.001 286.375);
--accent: hsl(60 4.8% 90%); --muted-foreground: oklch(0.552 0.016 285.938);
--accent-foreground: hsl(24 9.8% 10%); --accent: oklch(0.967 0.001 286.375);
--destructive: hsl(0 84.2% 60.2%); --accent-foreground: oklch(0.21 0.006 285.885);
--destructive-foreground: hsl(60 9.1% 97.8%); --destructive: oklch(0.577 0.245 27.325);
--border: hsl(20 5.9% 90%); --border: oklch(0.92 0.004 286.32);
--input: hsl(20 5.9% 75%); --input: oklch(0.92 0.004 286.32);
--ring: hsl(20 5.9% 75%); --ring: oklch(0.705 0.213 47.604);
--radius: 0.75rem; --chart-1: oklch(0.646 0.222 41.116);
--chart-1: hsl(12 76% 61%); --chart-2: oklch(0.6 0.118 184.704);
--chart-2: hsl(173 58% 39%); --chart-3: oklch(0.398 0.07 227.392);
--chart-3: hsl(197 37% 24%); --chart-4: oklch(0.828 0.189 84.429);
--chart-4: hsl(43 74% 66%); --chart-5: oklch(0.769 0.188 70.08);
--chart-5: hsl(27 87% 67%); --sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.141 0.005 285.823);
--sidebar-primary: oklch(0.705 0.213 47.604);
--sidebar-primary-foreground: oklch(0.98 0.016 73.684);
--sidebar-accent: oklch(0.967 0.001 286.375);
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.705 0.213 47.604);
} }
.dark { .dark {
--background: hsl(20 0% 8%); --background: oklch(0.20 0.006 285.885);
--foreground: hsl(60 9.1% 97.8%); --foreground: oklch(0.985 0 0);
--card: hsl(20 0% 10%); --card: oklch(0.21 0.006 285.885);
--card-foreground: hsl(60 9.1% 97.8%); --card-foreground: oklch(0.985 0 0);
--popover: hsl(20 0% 10%); --popover: oklch(0.21 0.006 285.885);
--popover-foreground: hsl(60 9.1% 97.8%); --popover-foreground: oklch(0.985 0 0);
--primary: hsl(20.5 90.2% 48.2%); --primary: oklch(0.6717 0.1946 41.93);
--primary-foreground: hsl(60 9.1% 97.8%); --primary-foreground: oklch(0.98 0.016 73.684);
--secondary: hsl(12 6.5% 15%); --secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: hsl(60 9.1% 97.8%); --secondary-foreground: oklch(0.985 0 0);
--muted: hsl(12 6.5% 25%); --muted: oklch(0.274 0.006 286.033);
--muted-foreground: hsl(24 5.4% 63.9%); --muted-foreground: oklch(0.705 0.015 286.067);
--accent: hsl(12 2.5% 15%); --accent: oklch(0.274 0.006 286.033);
--accent-foreground: hsl(60 9.1% 97.8%); --accent-foreground: oklch(0.985 0 0);
--destructive: hsl(0 72.2% 50.6%); --destructive: oklch(0.704 0.191 22.216);
--destructive-foreground: hsl(60 9.1% 97.8%); --border: oklch(1 0 0 / 10%);
--border: hsl(12 6.5% 15%); --input: oklch(1 0 0 / 15%);
--input: hsl(12 6.5% 35%); --ring: oklch(0.646 0.222 41.116);
--ring: hsl(12 6.5% 35%); --chart-1: oklch(0.488 0.243 264.376);
--chart-1: hsl(220 70% 50%); --chart-2: oklch(0.696 0.17 162.48);
--chart-2: hsl(160 60% 45%); --chart-3: oklch(0.769 0.188 70.08);
--chart-3: hsl(30 80% 55%); --chart-4: oklch(0.627 0.265 303.9);
--chart-4: hsl(280 65% 60%); --chart-5: oklch(0.645 0.246 16.439);
--chart-5: hsl(340 75% 55%); --sidebar: oklch(0.21 0.006 285.885);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.646 0.222 41.116);
--sidebar-primary-foreground: oklch(0.98 0.016 73.684);
--sidebar-accent: oklch(0.274 0.006 286.033);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.646 0.222 41.116);
} }
@theme inline { @theme inline {
--color-background: var(--background); --color-background: var(--background);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--color-card: var(--card); --color-card: var(--card);
--color-card-foreground: var(--card-foreground); --color-card-foreground: var(--card-foreground);
--color-popover: var(--popover); --color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground); --color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary); --color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground); --color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary); --color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground); --color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted); --color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground); --color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent); --color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground); --color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive); --color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground); --color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border); --color-border: var(--border);
--color-input: var(--input); --color-input: var(--input);
--color-ring: var(--ring); --color-ring: var(--ring);
--color-chart-1: var(--chart-1); --color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2); --color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3); --color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4); --color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5); --color-chart-5: var(--chart-5);
--radius-lg: var(--radius); --radius-lg: var(--radius);
--radius-md: calc(var(--radius) - 2px); --radius-md: calc(var(--radius) - 2px);
--radius-sm: calc(var(--radius) - 4px); --radius-sm: calc(var(--radius) - 4px);
} }
@layer base { @layer base {
*, *,
::after, ::after,
::before, ::before,
::backdrop, ::backdrop,
::file-selector-button { ::file-selector-button {
border-color: var(--color-gray-200, currentcolor); border-color: var(--color-gray-200, currentcolor);
} }
} }
@layer base { @layer base {
* { * {
@apply border-border; @apply border-border;
} }
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
} }
} }
p { p {
word-break: keep-all; word-break: keep-all;
white-space: normal; white-space: normal;
} }

View file

@ -11,7 +11,7 @@ import { AxiosResponse } from "axios";
import { IsSupporterKeyVisibleResponse } from "@server/routers/supporterKey"; import { IsSupporterKeyVisibleResponse } from "@server/routers/supporterKey";
import LicenseStatusProvider from "@app/providers/LicenseStatusProvider"; import LicenseStatusProvider from "@app/providers/LicenseStatusProvider";
import { GetLicenseStatusResponse } from "@server/routers/license"; import { GetLicenseStatusResponse } from "@server/routers/license";
import LicenseViolation from "./components/LicenseViolation"; import LicenseViolation from "@app/components/LicenseViolation";
import { cache } from "react"; import { cache } from "react";
import { NextIntlClientProvider } from "next-intl"; import { NextIntlClientProvider } from "next-intl";
import { getLocale } from "next-intl/server"; import { getLocale } from "next-intl/server";

View file

@ -9,94 +9,97 @@ import {
Fingerprint, Fingerprint,
Workflow, Workflow,
KeyRound, KeyRound,
TicketCheck TicketCheck,
User
} from "lucide-react"; } from "lucide-react";
export const orgLangingNavItems: SidebarNavItem[] = [ export type SidebarNavSection = {
{ heading: string;
title: "sidebarOverview", items: SidebarNavItem[];
href: "/{orgId}", };
icon: <Home className="h-4 w-4" />
}
];
export const rootNavItems: SidebarNavItem[] = [ export const orgNavSections: SidebarNavSection[] = [
{ {
title: "sidebarHome", heading: "General",
href: "/", items: [
icon: <Home className="h-4 w-4" />
}
];
export const orgNavItems: SidebarNavItem[] = [
{
title: "sidebarSites",
href: "/{orgId}/settings/sites",
icon: <Combine className="h-4 w-4" />
},
{
title: "sidebarResources",
href: "/{orgId}/settings/resources",
icon: <Waypoints className="h-4 w-4" />
},
{
title: "sidebarAccessControl",
href: "/{orgId}/settings/access",
icon: <Users className="h-4 w-4" />,
autoExpand: true,
children: [
{ {
title: "sidebarUsers", title: "sidebarSites",
href: "/{orgId}/settings/access/users", href: "/{orgId}/settings/sites",
children: [ icon: <Combine className="h-4 w-4" />
{
title: "sidebarInvitations",
href: "/{orgId}/settings/access/invitations"
}
]
}, },
{ {
title: "sidebarRoles", title: "sidebarResources",
href: "/{orgId}/settings/access/roles" href: "/{orgId}/settings/resources",
icon: <Waypoints className="h-4 w-4" />
} }
] ]
}, },
{ {
title: "sidebarShareableLinks", heading: "Access Control",
href: "/{orgId}/settings/share-links", items: [
icon: <LinkIcon className="h-4 w-4" /> {
title: "sidebarUsers",
href: "/{orgId}/settings/access/users",
icon: <User className="h-4 w-4" />
},
{
title: "sidebarRoles",
href: "/{orgId}/settings/access/roles",
icon: <Users className="h-4 w-4" />
},
{
title: "sidebarInvitations",
href: "/{orgId}/settings/access/invitations",
icon: <TicketCheck className="h-4 w-4" />
},
{
title: "sidebarShareableLinks",
href: "/{orgId}/settings/share-links",
icon: <LinkIcon className="h-4 w-4" />
}
]
}, },
{ {
title: "sidebarApiKeys", heading: "Organization",
href: "/{orgId}/settings/api-keys", items: [
icon: <KeyRound className="h-4 w-4" /> {
}, title: "sidebarApiKeys",
{ href: "/{orgId}/settings/api-keys",
title: "sidebarSettings", icon: <KeyRound className="h-4 w-4" />
href: "/{orgId}/settings/general", },
icon: <Settings className="h-4 w-4" /> {
title: "sidebarSettings",
href: "/{orgId}/settings/general",
icon: <Settings className="h-4 w-4" />
}
]
} }
]; ];
export const adminNavItems: SidebarNavItem[] = [ export const adminNavSections: SidebarNavSection[] = [
{ {
title: "sidebarAllUsers", heading: "Admin",
href: "/admin/users", items: [
icon: <Users className="h-4 w-4" /> {
}, title: "sidebarAllUsers",
{ href: "/admin/users",
title: "sidebarApiKeys", icon: <Users className="h-4 w-4" />
href: "/admin/api-keys", },
icon: <KeyRound className="h-4 w-4" /> {
}, title: "sidebarApiKeys",
{ href: "/admin/api-keys",
title: "sidebarIdentityProviders", icon: <KeyRound className="h-4 w-4" />
href: "/admin/idp", },
icon: <Fingerprint className="h-4 w-4" /> {
}, title: "sidebarIdentityProviders",
{ href: "/admin/idp",
title: "sidebarLicense", icon: <Fingerprint className="h-4 w-4" />
href: "/admin/license", },
icon: <TicketCheck className="h-4 w-4" /> {
title: "sidebarLicense",
href: "/admin/license",
icon: <TicketCheck className="h-4 w-4" />
}
]
} }
]; ];

View file

@ -6,11 +6,10 @@ import { ListUserOrgsResponse } from "@server/routers/org";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { cache } from "react"; import { cache } from "react";
import OrganizationLanding from "./components/OrganizationLanding"; import OrganizationLanding from "@app/components/OrganizationLanding";
import { pullEnv } from "@app/lib/pullEnv"; import { pullEnv } from "@app/lib/pullEnv";
import { cleanRedirect } from "@app/lib/cleanRedirect"; import { cleanRedirect } from "@app/lib/cleanRedirect";
import { Layout } from "@app/components/Layout"; import { Layout } from "@app/components/Layout";
import { rootNavItems } from "./navigation";
import { InitialSetupCompleteResponse } from "@server/routers/auth"; import { InitialSetupCompleteResponse } from "@server/routers/auth";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
@ -73,17 +72,15 @@ export default async function Page(props: {
} }
} }
// Check for pangolin-last-org cookie and redirect if valid
const allCookies = await cookies(); const allCookies = await cookies();
const lastOrgCookie = allCookies.get("pangolin-last-org")?.value; const lastOrgCookie = allCookies.get("pangolin-last-org")?.value;
if (lastOrgCookie && orgs.length > 0) { if (lastOrgCookie && orgs.length > 0) {
// Check if the last org from cookie exists in user's organizations const lastOrgExists = orgs.some((org) => org.orgId === lastOrgCookie);
const lastOrgExists = orgs.some(org => org.orgId === lastOrgCookie);
if (lastOrgExists) { if (lastOrgExists) {
redirect(`/${lastOrgCookie}`); redirect(`/${lastOrgCookie}`);
} else { } else {
const ownedOrg = orgs.find(org => org.isOwner); const ownedOrg = orgs.find((org) => org.isOwner);
if (ownedOrg) { if (ownedOrg) {
redirect(`/${ownedOrg.orgId}`); redirect(`/${ownedOrg.orgId}`);
} else { } else {
@ -94,7 +91,7 @@ export default async function Page(props: {
return ( return (
<UserProvider user={user}> <UserProvider user={user}>
<Layout orgs={orgs} navItems={rootNavItems} showBreadcrumbs={false}> <Layout orgs={orgs} navItems={[]}>
<div className="w-full max-w-md mx-auto md:mt-32 mt-4"> <div className="w-full max-w-md mx-auto md:mt-32 mt-4">
<OrganizationLanding <OrganizationLanding
disableCreateOrg={ disableCreateOrg={

View file

@ -6,7 +6,6 @@ import UserProvider from "@app/providers/UserProvider";
import { Metadata } from "next"; import { Metadata } from "next";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { cache } from "react"; import { cache } from "react";
import { rootNavItems } from "../navigation";
import { ListUserOrgsResponse } from "@server/routers/org"; import { ListUserOrgsResponse } from "@server/routers/org";
import { internal } from "@app/lib/api"; import { internal } from "@app/lib/api";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
@ -54,11 +53,7 @@ export default async function SetupLayout({
return ( return (
<> <>
<UserProvider user={user}> <UserProvider user={user}>
<Layout <Layout navItems={[]} orgs={orgs}>
navItems={rootNavItems}
showBreadcrumbs={false}
orgs={orgs}
>
<div className="w-full max-w-2xl mx-auto md:mt-32 mt-4"> <div className="w-full max-w-2xl mx-auto md:mt-32 mt-4">
{children} {children}
</div> </div>

View file

@ -1,3 +0,0 @@
"use client";
export function AuthFooter() {}

View file

@ -1,42 +0,0 @@
"use client";
import { usePathname } from "next/navigation";
import Link from "next/link";
import { ChevronRight } from "lucide-react";
import { cn } from "@app/lib/cn";
interface BreadcrumbItem {
label: string;
href: string;
}
export function Breadcrumbs() {
const pathname = usePathname();
const segments = pathname.split("/").filter(Boolean);
const breadcrumbs: BreadcrumbItem[] = segments.map((segment, index) => {
const href = `/${segments.slice(0, index + 1).join("/")}`;
let label = decodeURIComponent(segment);
return { label, href };
});
return (
<nav className="flex items-center space-x-1 text-muted-foreground">
{breadcrumbs.map((crumb, index) => (
<div key={crumb.href} className="flex items-center flex-nowrap">
{index !== 0 && <ChevronRight className="h-4 w-4 flex-shrink-0" />}
<Link
href={crumb.href}
className={cn(
"ml-1 hover:text-foreground whitespace-nowrap",
index === breadcrumbs.length - 1 &&
"text-foreground font-medium"
)}
>
{crumb.label}
</Link>
</div>
))}
</nav>
);
}

View file

@ -72,7 +72,7 @@ export default function InviteUserForm({
const formSchema = z.object({ const formSchema = z.object({
string: z.string().refine((val) => val === string, { string: z.string().refine((val) => val === string, {
message: t('inviteErrorInvalidConfirmation') message: t("inviteErrorInvalidConfirmation")
}) })
}); });
@ -108,7 +108,9 @@ export default function InviteUserForm({
<CredenzaTitle>{title}</CredenzaTitle> <CredenzaTitle>{title}</CredenzaTitle>
</CredenzaHeader> </CredenzaHeader>
<CredenzaBody> <CredenzaBody>
<div className="mb-4 break-all overflow-hidden">{dialog}</div> <div className="mb-4 break-all overflow-hidden">
{dialog}
</div>
<Form {...form}> <Form {...form}>
<form <form
onSubmit={form.handleSubmit(onSubmit)} onSubmit={form.handleSubmit(onSubmit)}
@ -132,9 +134,10 @@ export default function InviteUserForm({
</CredenzaBody> </CredenzaBody>
<CredenzaFooter> <CredenzaFooter>
<CredenzaClose asChild> <CredenzaClose asChild>
<Button variant="outline">{t('close')}</Button> <Button variant="outline">{t("close")}</Button>
</CredenzaClose> </CredenzaClose>
<Button <Button
variant={"destructive"}
type="submit" type="submit"
form="confirm-delete-form" form="confirm-delete-form"
loading={loading} loading={loading}

View file

@ -40,7 +40,7 @@ export default function CopyTextBox({
> >
<pre <pre
ref={textRef} ref={textRef}
className={`p-2 pr-16 text-sm w-full ${ className={`p-4 pr-16 text-sm w-full ${
wrapText wrapText
? "whitespace-pre-wrap break-words" ? "whitespace-pre-wrap break-words"
: "overflow-x-auto" : "overflow-x-auto"

View file

@ -32,7 +32,7 @@ const CopyToClipboard = ({ text, displayText, isLink }: CopyToClipboardProps) =>
href={text} href={text}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="truncate hover:underline" className="truncate hover:underline text-sm"
style={{ maxWidth: "100%" }} // Ensures truncation works within parent style={{ maxWidth: "100%" }} // Ensures truncation works within parent
title={text} // Shows full text on hover title={text} // Shows full text on hover
> >
@ -40,7 +40,7 @@ const CopyToClipboard = ({ text, displayText, isLink }: CopyToClipboardProps) =>
</Link> </Link>
) : ( ) : (
<span <span
className="truncate" className="truncate text-sm"
style={{ style={{
maxWidth: "100%", maxWidth: "100%",
display: "block", display: "block",

View file

@ -1,276 +1,78 @@
"use client"; import React from "react";
import React, { useEffect, useState } from "react";
import { SidebarNav } from "@app/components/SidebarNav";
import { OrgSelector } from "@app/components/OrgSelector";
import { cn } from "@app/lib/cn"; import { cn } from "@app/lib/cn";
import { ListUserOrgsResponse } from "@server/routers/org"; import { ListUserOrgsResponse } from "@server/routers/org";
import SupporterStatus from "@app/components/SupporterStatus"; import type { SidebarNavSection } from "@app/app/navigation";
import { Button } from "@app/components/ui/button"; import { LayoutSidebar } from "@app/components/LayoutSidebar";
import { ExternalLink, Menu, X, Server } from "lucide-react"; import { LayoutHeader } from "@app/components/LayoutHeader";
import Image from "next/image"; import { LayoutMobileMenu } from "@app/components/LayoutMobileMenu";
import ProfileIcon from "@app/components/ProfileIcon"; import { cookies } from "next/headers";
import {
Sheet,
SheetContent,
SheetTrigger,
SheetTitle,
SheetDescription
} from "@app/components/ui/sheet";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { Breadcrumbs } from "@app/components/Breadcrumbs";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useUserContext } from "@app/hooks/useUserContext";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import { useTheme } from "next-themes";
import { useTranslations } from "next-intl";
interface LayoutProps { interface LayoutProps {
children: React.ReactNode; children: React.ReactNode;
orgId?: string; orgId?: string;
orgs?: ListUserOrgsResponse["orgs"]; orgs?: ListUserOrgsResponse["orgs"];
navItems?: Array<{ navItems?: SidebarNavSection[];
title: string;
href: string;
icon?: React.ReactNode;
children?: Array<{
title: string;
href: string;
icon?: React.ReactNode;
}>;
}>;
showSidebar?: boolean; showSidebar?: boolean;
showBreadcrumbs?: boolean;
showHeader?: boolean; showHeader?: boolean;
showTopBar?: boolean; showTopBar?: boolean;
defaultSidebarCollapsed?: boolean;
} }
export function Layout({ export async function Layout({
children, children,
orgId, orgId,
orgs, orgs,
navItems = [], navItems = [],
showSidebar = true, showSidebar = true,
showBreadcrumbs = true,
showHeader = true, showHeader = true,
showTopBar = true showTopBar = true,
defaultSidebarCollapsed = false
}: LayoutProps) { }: LayoutProps) {
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); const allCookies = await cookies();
const { env } = useEnvContext(); const sidebarStateCookie = allCookies.get("pangolin-sidebar-state")?.value;
const pathname = usePathname();
const isAdminPage = pathname?.startsWith("/admin"); const initialSidebarCollapsed = sidebarStateCookie === "collapsed" ||
const { user } = useUserContext(); (sidebarStateCookie !== "expanded" && defaultSidebarCollapsed);
const { isUnlocked } = useLicenseStatusContext();
const { theme } = useTheme();
const [path, setPath] = useState<string>(""); // Default logo path
useEffect(() => {
function getPath() {
let lightOrDark = theme;
if (theme === "system" || !theme) {
lightOrDark = window.matchMedia("(prefers-color-scheme: dark)")
.matches
? "dark"
: "light";
}
if (lightOrDark === "light") {
// return "/logo/word_mark_black.png";
return "/logo/pangolin_orange.svg";
}
// return "/logo/word_mark_white.png";
return "/logo/pangolin_orange.svg";
}
setPath(getPath());
}, [theme, env]);
const t = useTranslations();
return ( return (
<div className="flex flex-col h-screen overflow-hidden"> <div className="flex h-screen overflow-hidden">
{/* Full width header */} {/* Desktop Sidebar */}
{showHeader && ( {showSidebar && (
<div className="border-b shrink-0 bg-card"> <LayoutSidebar
<div className="h-16 flex items-center px-4"> orgId={orgId}
<div className="flex items-center gap-4"> orgs={orgs}
{showSidebar && ( navItems={navItems}
<div className="md:hidden"> defaultSidebarCollapsed={initialSidebarCollapsed}
<Sheet />
open={isMobileMenuOpen}
onOpenChange={setIsMobileMenuOpen}
>
<SheetTrigger asChild>
<Button variant="ghost" size="icon">
<Menu className="h-6 w-6" />
</Button>
</SheetTrigger>
<SheetContent
side="left"
className="w-64 p-0 flex flex-col h-full"
>
<SheetTitle className="sr-only">
{t('navbar')}
</SheetTitle>
<SheetDescription className="sr-only">
{t('navbarDescription')}
</SheetDescription>
<div className="flex-1 overflow-y-auto">
<div className="p-4">
<SidebarNav
items={navItems}
onItemClick={() =>
setIsMobileMenuOpen(
false
)
}
/>
</div>
{!isAdminPage &&
user.serverAdmin && (
<div className="p-4 border-t">
<Link
href="/admin"
className="flex items-center gap-3 text-muted-foreground hover:text-foreground transition-colors px-3 py-2 rounded-md w-full"
onClick={() =>
setIsMobileMenuOpen(
false
)
}
>
<Server className="h-4 w-4" />
{t('serverAdmin')}
</Link>
</div>
)}
</div>
<div className="p-4 space-y-4 border-t shrink-0">
<SupporterStatus />
<OrgSelector
orgId={orgId}
orgs={orgs}
/>
{env?.app?.version && (
<div className="text-xs text-muted-foreground text-center">
v{env.app.version}
</div>
)}
</div>
</SheetContent>
</Sheet>
</div>
)}
<Link
href="/"
className="flex items-center hidden md:block"
>
{path && (
<Image
src={path}
alt="Pangolin Logo"
width={35}
height={35}
priority={true}
quality={25}
/>
)}
</Link>
{showBreadcrumbs && (
<div className="hidden md:block overflow-x-auto scrollbar-hide">
<Breadcrumbs />
</div>
)}
</div>
{showTopBar && (
<div className="ml-auto flex items-center justify-end md:justify-between">
<div className="hidden md:flex items-center space-x-3 mr-6">
<Link
href="https://docs.fossorial.io"
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground transition-colors"
>
{t('navbarDocsLink')}
</Link>
</div>
<div>
<ProfileIcon />
</div>
</div>
)}
</div>
{showBreadcrumbs && (
<div className="md:hidden px-4 pb-2 overflow-x-auto scrollbar-hide">
<Breadcrumbs />
</div>
)}
</div>
)} )}
<div className="flex flex-1 overflow-hidden"> {/* Main content area */}
{/* Desktop Sidebar */} <div
{showSidebar && ( className={cn(
<div className="hidden md:flex w-64 border-r bg-card flex-col h-full shrink-0"> "flex-1 flex flex-col h-full min-w-0",
<div className="flex-1 overflow-y-auto"> !showSidebar && "w-full"
<div className="p-4"> )}
<SidebarNav items={navItems} /> >
</div> {/* Mobile header */}
{!isAdminPage && user.serverAdmin && ( {showHeader && (
<div className="p-4 border-t"> <LayoutMobileMenu
<Link orgId={orgId}
href="/admin" orgs={orgs}
className="flex items-center gap-3 text-muted-foreground hover:text-foreground transition-colors px-3 py-2 rounded-md w-full" navItems={navItems}
> showSidebar={showSidebar}
<Server className="h-4 w-4" /> showTopBar={showTopBar}
{t('serverAdmin')} />
</Link>
</div>
)}
</div>
<div className="p-4 space-y-4 border-t shrink-0">
<SupporterStatus />
<OrgSelector orgId={orgId} orgs={orgs} />
<div className="space-y-2">
<div className="text-xs text-muted-foreground text-center">
<Link
href="https://github.com/fosrl/pangolin"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-1"
>
{!isUnlocked()
? t('communityEdition')
: t('commercialEdition')}
<ExternalLink size={12} />
</Link>
</div>
{env?.app?.version && (
<div className="text-xs text-muted-foreground text-center">
v{env.app.version}
</div>
)}
</div>
</div>
</div>
)} )}
{/* Desktop header */}
{showHeader && <LayoutHeader showTopBar={showTopBar} />}
{/* Main content */} {/* Main content */}
<div <main className="flex-1 overflow-y-auto p-3 md:p-6 w-full">
className={cn( <div className="container mx-auto max-w-12xl mb-12">
"flex-1 flex flex-col h-full min-w-0", {children}
!showSidebar && "w-full" </div>
)} </main>
>
<main className="flex-1 overflow-y-auto p-3 md:p-6 w-full">
<div className="container mx-auto max-w-12xl mb-12">
{children}
</div>
</main>
</div>
</div> </div>
</div> </div>
); );

View file

@ -0,0 +1,73 @@
"use client";
import React, { useEffect, useState } from "react";
import { cn } from "@app/lib/cn";
import Image from "next/image";
import Link from "next/link";
import ProfileIcon from "@app/components/ProfileIcon";
import ThemeSwitcher from "@app/components/ThemeSwitcher";
import { useTheme } from "next-themes";
interface LayoutHeaderProps {
showTopBar: boolean;
}
export function LayoutHeader({ showTopBar }: LayoutHeaderProps) {
const { theme } = useTheme();
const [path, setPath] = useState<string>("");
useEffect(() => {
function getPath() {
let lightOrDark = theme;
if (theme === "system" || !theme) {
lightOrDark = window.matchMedia("(prefers-color-scheme: dark)")
.matches
? "dark"
: "light";
}
if (lightOrDark === "light") {
return "/logo/word_mark_black.png";
}
return "/logo/word_mark_white.png";
}
setPath(getPath());
}, [theme]);
return (
<div className="shrink-0 hidden md:block">
<div className="px-6 py-2">
<div className="container mx-auto max-w-12xl">
<div className="h-16 flex items-center justify-between">
<div className="flex items-center">
<Link href="/" className="flex items-center">
{path && (
<Image
src={path}
alt="Pangolin"
width={98}
height={32}
className="h-8 w-auto"
/>
)}
</Link>
</div>
{/* Profile controls on the right */}
{showTopBar && (
<div className="flex items-center space-x-2">
<ThemeSwitcher />
<ProfileIcon />
</div>
)}
</div>
</div>
</div>
</div>
);
}
export default LayoutHeader;

View file

@ -0,0 +1,142 @@
"use client";
import React, { useState } from "react";
import { SidebarNav } from "@app/components/SidebarNav";
import { OrgSelector } from "@app/components/OrgSelector";
import { cn } from "@app/lib/cn";
import { ListUserOrgsResponse } from "@server/routers/org";
import SupporterStatus from "@app/components/SupporterStatus";
import { Button } from "@app/components/ui/button";
import { Menu, Server } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useUserContext } from "@app/hooks/useUserContext";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useTranslations } from "next-intl";
import ProfileIcon from "@app/components/ProfileIcon";
import ThemeSwitcher from "@app/components/ThemeSwitcher";
import type { SidebarNavSection } from "@app/app/navigation";
import {
Sheet,
SheetContent,
SheetTrigger,
SheetTitle,
SheetDescription
} from "@app/components/ui/sheet";
import { Abel } from "next/font/google";
interface LayoutMobileMenuProps {
orgId?: string;
orgs?: ListUserOrgsResponse["orgs"];
navItems: SidebarNavSection[];
showSidebar: boolean;
showTopBar: boolean;
}
export function LayoutMobileMenu({
orgId,
orgs,
navItems,
showSidebar,
showTopBar
}: LayoutMobileMenuProps) {
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const pathname = usePathname();
const isAdminPage = pathname?.startsWith("/admin");
const { user } = useUserContext();
const { env } = useEnvContext();
const t = useTranslations();
return (
<div className="shrink-0 md:hidden">
<div className="h-16 flex items-center px-4">
<div className="flex items-center gap-4">
{showSidebar && (
<div>
<Sheet
open={isMobileMenuOpen}
onOpenChange={setIsMobileMenuOpen}
>
<SheetTrigger asChild>
<Button variant="ghost" size="icon">
<Menu className="h-6 w-6" />
</Button>
</SheetTrigger>
<SheetContent
side="left"
className="w-64 p-0 flex flex-col h-full"
>
<SheetTitle className="sr-only">
{t("navbar")}
</SheetTitle>
<SheetDescription className="sr-only">
{t("navbarDescription")}
</SheetDescription>
<div className="flex-1 overflow-y-auto">
<div className="p-4">
<OrgSelector
orgId={orgId}
orgs={orgs}
/>
</div>
<div className="px-4">
{!isAdminPage &&
user.serverAdmin && (
<div className="pb-3">
<Link
href="/admin"
className={cn(
"flex items-center rounded transition-colors text-muted-foreground hover:text-foreground text-sm w-full hover:bg-secondary/50 dark:hover:bg-secondary/20 rounded-md px-3 py-1.5"
)}
onClick={() =>
setIsMobileMenuOpen(
false
)
}
>
<span className="flex-shrink-0 mr-2">
<Server className="h-4 w-4" />
</span>
<span>
{t(
"serverAdmin"
)}
</span>
</Link>
</div>
)}
<SidebarNav
sections={navItems}
onItemClick={() =>
setIsMobileMenuOpen(false)
}
/>
</div>
</div>
<div className="p-4 space-y-4 border-t shrink-0">
<SupporterStatus />
{env?.app?.version && (
<div className="text-xs text-muted-foreground text-center">
v{env.app.version}
</div>
)}
</div>
</SheetContent>
</Sheet>
</div>
)}
</div>
{showTopBar && (
<div className="ml-auto flex items-center justify-end">
<div className="flex items-center space-x-2">
<ThemeSwitcher />
<ProfileIcon />
</div>
</div>
)}
</div>
</div>
);
}
export default LayoutMobileMenu;

View file

@ -0,0 +1,178 @@
"use client";
import React, { useEffect, useState } from "react";
import { SidebarNav } from "@app/components/SidebarNav";
import { OrgSelector } from "@app/components/OrgSelector";
import { cn } from "@app/lib/cn";
import { ListUserOrgsResponse } from "@server/routers/org";
import SupporterStatus from "@app/components/SupporterStatus";
import { ExternalLink, Server, BookOpenText } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useUserContext } from "@app/hooks/useUserContext";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useTranslations } from "next-intl";
import type { SidebarNavSection } from "@app/app/navigation";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from "@app/components/ui/tooltip";
interface LayoutSidebarProps {
orgId?: string;
orgs?: ListUserOrgsResponse["orgs"];
navItems: SidebarNavSection[];
defaultSidebarCollapsed: boolean;
}
export function LayoutSidebar({
orgId,
orgs,
navItems,
defaultSidebarCollapsed
}: LayoutSidebarProps) {
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(
defaultSidebarCollapsed
);
const pathname = usePathname();
const isAdminPage = pathname?.startsWith("/admin");
const { user } = useUserContext();
const { isUnlocked } = useLicenseStatusContext();
const { env } = useEnvContext();
const t = useTranslations();
const setSidebarStateCookie = (collapsed: boolean) => {
if (typeof window !== "undefined") {
const isSecure = window.location.protocol === "https:";
document.cookie = `pangolin-sidebar-state=${collapsed ? "collapsed" : "expanded"}; path=/; max-age=${60 * 60 * 24 * 30}; samesite=lax${isSecure ? "; secure" : ""}`;
}
};
useEffect(() => {
setSidebarStateCookie(isSidebarCollapsed);
}, [isSidebarCollapsed]);
return (
<div
className={cn(
"hidden md:flex border-r bg-card flex-col h-full shrink-0 relative",
isSidebarCollapsed ? "w-16" : "w-64"
)}
>
<div className="flex-1 overflow-y-auto">
<div className="p-4">
<OrgSelector
orgId={orgId}
orgs={orgs}
isCollapsed={isSidebarCollapsed}
/>
</div>
<div className="px-4 pt-1">
{!isAdminPage && user.serverAdmin && (
<div className="pb-4">
<Link
href="/admin"
className={cn(
"flex items-center rounded transition-colors text-muted-foreground hover:text-foreground text-sm w-full hover:bg-secondary/50 dark:hover:bg-secondary/20 rounded-md",
isSidebarCollapsed
? "px-2 py-2 justify-center"
: "px-3 py-1.5"
)}
title={
isSidebarCollapsed
? t("serverAdmin")
: undefined
}
>
<span
className={cn(
"flex-shrink-0",
!isSidebarCollapsed && "mr-2"
)}
>
<Server className="h-4 w-4" />
</span>
{!isSidebarCollapsed && (
<span>{t("serverAdmin")}</span>
)}
</Link>
</div>
)}
<SidebarNav
sections={navItems}
isCollapsed={isSidebarCollapsed}
/>
</div>
</div>
<div className="p-4 space-y-4 border-t shrink-0">
<SupporterStatus isCollapsed={isSidebarCollapsed} />
{!isSidebarCollapsed && (
<div className="space-y-2">
<div className="text-xs text-muted-foreground text-center">
<Link
href="https://github.com/fosrl/pangolin"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-1"
>
{!isUnlocked()
? t("communityEdition")
: t("commercialEdition")}
<ExternalLink size={12} />
</Link>
</div>
<div className="text-xs text-muted-foreground ">
<Link
href="https://docs.fossorial.io/"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-1"
>
{t("documentation")}
<BookOpenText size={12} />
</Link>
</div>
{env?.app?.version && (
<div className="text-xs text-muted-foreground text-center">
v{env.app.version}
</div>
)}
</div>
)}
</div>
{/* Collapse button */}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() =>
setIsSidebarCollapsed(!isSidebarCollapsed)
}
className="cursor-pointer absolute -right-2.5 top-1/2 transform -translate-y-1/2 w-2 h-8 rounded-full flex items-center justify-center transition-all duration-200 ease-in-out hover:scale-110 group"
aria-label={
isSidebarCollapsed
? "Expand sidebar"
: "Collapse sidebar"
}
>
<div className="w-0.5 h-4 bg-current opacity-30 group-hover:opacity-100 transition-opacity duration-200" />
</button>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
<p>
{isSidebarCollapsed
? t("sidebarExpand")
: t("sidebarCollapse")}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
);
}
export default LayoutSidebar;

View file

@ -1,55 +1,55 @@
import { useLocale } from "next-intl"; import { useLocale } from "next-intl";
import LocaleSwitcherSelect from './LocaleSwitcherSelect'; import LocaleSwitcherSelect from "./LocaleSwitcherSelect";
export default function LocaleSwitcher() { export default function LocaleSwitcher() {
const locale = useLocale(); const locale = useLocale();
return ( return (
<LocaleSwitcherSelect <LocaleSwitcherSelect
label="Select language" label="Select language"
defaultValue={locale} defaultValue={locale}
items={[ items={[
{ {
value: 'en-US', value: "en-US",
label: 'English' label: "English"
}, },
{ {
value: 'fr-FR', value: "fr-FR",
label: "Français" label: "Français"
}, },
{ {
value: 'de-DE', value: "de-DE",
label: 'Deutsch' label: "Deutsch"
}, },
{ {
value: 'it-IT', value: "it-IT",
label: 'Italiano' label: "Italiano"
}, },
{ {
value: 'nl-NL', value: "nl-NL",
label: 'Nederlands' label: "Nederlands"
}, },
{ {
value: 'pl-PL', value: "pl-PL",
label: 'Polski' label: "Polski"
}, },
{ {
value: 'pt-PT', value: "pt-PT",
label: 'Português' label: "Português"
}, },
{ {
value: 'es-ES', value: "es-ES",
label: 'Español' label: "Español"
}, },
{ {
value: 'tr-TR', value: "tr-TR",
label: 'Türkçe' label: "Türkçe"
}, },
{ {
value: 'zh-CN', value: "zh-CN",
label: '简体中文' label: "简体中文"
} }
]} ]}
/> />
); );
} }

View file

@ -1,71 +1,71 @@
'use client'; "use client";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger DropdownMenuTrigger
} from '@app/components/ui/dropdown-menu'; } from "@app/components/ui/dropdown-menu";
import { Button } from '@app/components/ui/button'; import { Button } from "@app/components/ui/button";
import { Check, Globe, Languages } from 'lucide-react'; import { Check, Globe, Languages } from "lucide-react";
import clsx from 'clsx'; import clsx from "clsx";
import { useTransition } from 'react'; import { useTransition } from "react";
import { Locale } from '@/i18n/config'; import { Locale } from "@/i18n/config";
import { setUserLocale } from '@/services/locale'; import { setUserLocale } from "@/services/locale";
type Props = { type Props = {
defaultValue: string; defaultValue: string;
items: Array<{ value: string; label: string }>; items: Array<{ value: string; label: string }>;
label: string; label: string;
}; };
export default function LocaleSwitcherSelect({ export default function LocaleSwitcherSelect({
defaultValue, defaultValue,
items, items,
label label
}: Props) { }: Props) {
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
function onChange(value: string) { function onChange(value: string) {
const locale = value as Locale; const locale = value as Locale;
startTransition(() => { startTransition(() => {
setUserLocale(locale); setUserLocale(locale);
}); });
} }
const selected = items.find((item) => item.value === defaultValue); const selected = items.find((item) => item.value === defaultValue);
return ( return (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button
variant="ghost" variant="ghost"
className={clsx( className={clsx(
'w-full rounded-sm h-8 gap-2 justify-start font-normal', "w-full rounded-sm h-8 gap-2 justify-start font-normal",
isPending && 'pointer-events-none' isPending && "pointer-events-none"
)} )}
aria-label={label} aria-label={label}
> >
<Languages className="h-4 w-4" /> <Languages className="h-4 w-4" />
<span className="text-left flex-1"> <span className="text-left flex-1">
{selected?.label ?? label} {selected?.label ?? label}
</span> </span>
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[8rem]"> <DropdownMenuContent align="end" className="min-w-[8rem]">
{items.map((item) => ( {items.map((item) => (
<DropdownMenuItem <DropdownMenuItem
key={item.value} key={item.value}
onClick={() => onChange(item.value)} onClick={() => onChange(item.value)}
className="flex items-center gap-2" className="flex items-center gap-2"
> >
{item.value === defaultValue && ( {item.value === defaultValue && (
<Check className="h-4 w-4" /> <Check className="h-4 w-4" />
)} )}
<span>{item.label}</span> <span>{item.label}</span>
</DropdownMenuItem> </DropdownMenuItem>
))} ))}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
); );
} }

View file

@ -15,10 +15,16 @@ import {
PopoverContent, PopoverContent,
PopoverTrigger PopoverTrigger
} from "@app/components/ui/popover"; } from "@app/components/ui/popover";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@app/components/ui/tooltip";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import { cn } from "@app/lib/cn"; import { cn } from "@app/lib/cn";
import { ListUserOrgsResponse } from "@server/routers/org"; import { ListUserOrgsResponse } from "@server/routers/org";
import { Check, ChevronsUpDown, Plus } from "lucide-react"; import { Check, ChevronsUpDown, Plus, Building2, Users } from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useState } from "react"; import { useState } from "react";
import { useUserContext } from "@app/hooks/useUserContext"; import { useUserContext } from "@app/hooks/useUserContext";
@ -27,94 +33,110 @@ import { useTranslations } from "next-intl";
interface OrgSelectorProps { interface OrgSelectorProps {
orgId?: string; orgId?: string;
orgs?: ListUserOrgsResponse["orgs"]; orgs?: ListUserOrgsResponse["orgs"];
isCollapsed?: boolean;
} }
export function OrgSelector({ orgId, orgs }: OrgSelectorProps) { export function OrgSelector({ orgId, orgs, isCollapsed = false }: OrgSelectorProps) {
const { user } = useUserContext(); const { user } = useUserContext();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const router = useRouter(); const router = useRouter();
const { env } = useEnvContext(); const { env } = useEnvContext();
const t = useTranslations(); const t = useTranslations();
return ( const selectedOrg = orgs?.find((org) => org.orgId === orgId);
const orgSelectorContent = (
<Popover open={open} onOpenChange={setOpen}> <Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button
variant="outline" variant="secondary"
size="lg" size={isCollapsed ? "icon" : "lg"}
role="combobox" role="combobox"
aria-expanded={open} aria-expanded={open}
className="w-full h-12 px-3 py-4 bg-neutral hover:bg-neutral" className={cn(
"shadow-xs",
isCollapsed ? "w-8 h-8" : "w-full h-12 px-3 py-4"
)}
> >
<div className="flex items-center justify-between w-full min-w-0"> {isCollapsed ? (
<div className="flex flex-col items-start min-w-0 flex-1"> <Building2 className="h-4 w-4" />
<span className="font-bold text-sm"> ) : (
{t('org')} <div className="flex items-center justify-between w-full min-w-0">
</span> <div className="flex items-center min-w-0 flex-1">
<span className="text-sm text-muted-foreground truncate w-full"> <Building2 className="h-4 w-4 mr-2 shrink-0" />
{orgId <div className="flex flex-col items-start min-w-0 flex-1">
? orgs?.find( <span className="font-bold text-sm">
(org) => {t('org')}
org.orgId === </span>
orgId <span className="text-sm text-muted-foreground truncate w-full text-left">
)?.name {selectedOrg?.name || t('noneSelected')}
: t('noneSelected')} </span>
</span> </div>
</div>
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50 ml-2" />
</div> </div>
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50 ml-2" /> )}
</div>
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-[280px] p-0"> <PopoverContent className="w-[320px] p-0" align="start">
<Command> <Command className="rounded-lg">
<CommandInput placeholder={t('searchProgress')} /> <CommandInput
<CommandEmpty> placeholder={t('searchProgress')}
{t('orgNotFound2')} className="border-0 focus:ring-0"
/>
<CommandEmpty className="py-6 text-center">
<div className="text-muted-foreground text-sm">
{t('orgNotFound2')}
</div>
</CommandEmpty> </CommandEmpty>
{(!env.flags.disableUserCreateOrg || {(!env.flags.disableUserCreateOrg || user.serverAdmin) && (
user.serverAdmin) && (
<> <>
<CommandGroup heading={t('create')}> <CommandGroup heading={t('create')} className="py-2">
<CommandList> <CommandList>
<CommandItem <CommandItem
onSelect={( onSelect={() => {
currentValue setOpen(false);
) => { router.push("/setup");
router.push(
"/setup"
);
}} }}
className="mx-2 rounded-md"
> >
<Plus className="mr-2 h-4 w-4" /> <div className="flex items-center justify-center w-8 h-8 rounded-lg bg-primary/10 mr-3">
{t('setupNewOrg')} <Plus className="h-4 w-4 text-primary" />
</div>
<div className="flex flex-col">
<span className="font-medium">{t('setupNewOrg')}</span>
<span className="text-xs text-muted-foreground">Create a new organization</span>
</div>
</CommandItem> </CommandItem>
</CommandList> </CommandList>
</CommandGroup> </CommandGroup>
<CommandSeparator /> <CommandSeparator className="my-2" />
</> </>
)} )}
<CommandGroup heading={t('orgs')}> <CommandGroup heading={t('orgs')} className="py-2">
<CommandList> <CommandList>
{orgs?.map((org) => ( {orgs?.map((org) => (
<CommandItem <CommandItem
key={org.orgId} key={org.orgId}
onSelect={( onSelect={() => {
currentValue setOpen(false);
) => { router.push(`/${org.orgId}/settings`);
router.push(
`/${org.orgId}/settings`
);
}} }}
className="mx-2 rounded-md"
> >
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-muted mr-3">
<Users className="h-4 w-4 text-muted-foreground" />
</div>
<div className="flex flex-col flex-1">
<span className="font-medium">{org.name}</span>
<span className="text-xs text-muted-foreground">Organization</span>
</div>
<Check <Check
className={cn( className={cn(
"mr-2 h-4 w-4", "h-4 w-4 text-primary",
orgId === org.orgId orgId === org.orgId ? "opacity-100" : "opacity-0"
? "opacity-100"
: "opacity-0"
)} )}
/> />
{org.name}
</CommandItem> </CommandItem>
))} ))}
</CommandList> </CommandList>
@ -123,4 +145,24 @@ export function OrgSelector({ orgId, orgs }: OrgSelectorProps) {
</PopoverContent> </PopoverContent>
</Popover> </Popover>
); );
if (isCollapsed) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
{orgSelectorContent}
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
<div className="text-center">
<p className="font-medium">{selectedOrg?.name || t('noneSelected')}</p>
<p className="text-xs text-muted-foreground">{t('org')}</p>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
return orgSelectorContent;
} }

View file

@ -23,10 +23,9 @@ import Disable2FaForm from "./Disable2FaForm";
import Enable2FaForm from "./Enable2FaForm"; import Enable2FaForm from "./Enable2FaForm";
import SupporterStatus from "./SupporterStatus"; import SupporterStatus from "./SupporterStatus";
import { UserType } from "@server/types/UserTypes"; import { UserType } from "@server/types/UserTypes";
import LocaleSwitcher from '@app/components/LocaleSwitcher'; import LocaleSwitcher from "@app/components/LocaleSwitcher";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
export default function ProfileIcon() { export default function ProfileIcon() {
const { setTheme, theme } = useTheme(); const { setTheme, theme } = useTheme();
const { env } = useEnvContext(); const { env } = useEnvContext();
@ -57,10 +56,10 @@ export default function ProfileIcon() {
function logout() { function logout() {
api.post("/auth/logout") api.post("/auth/logout")
.catch((e) => { .catch((e) => {
console.error(t('logoutError'), e); console.error(t("logoutError"), e);
toast({ toast({
title: t('logoutError'), title: t("logoutError"),
description: formatAxiosError(e, t('logoutError')) description: formatAxiosError(e, t("logoutError"))
}); });
}) })
.then(() => { .then(() => {
@ -74,104 +73,93 @@ export default function ProfileIcon() {
<Enable2FaForm open={openEnable2fa} setOpen={setOpenEnable2fa} /> <Enable2FaForm open={openEnable2fa} setOpen={setOpenEnable2fa} />
<Disable2FaForm open={openDisable2fa} setOpen={setOpenDisable2fa} /> <Disable2FaForm open={openDisable2fa} setOpen={setOpenDisable2fa} />
<div className="flex items-center md:gap-2 grow min-w-0 gap-2 md:gap-0"> <DropdownMenu>
<span className="truncate max-w-full font-medium min-w-0"> <DropdownMenuTrigger asChild>
{user.email || user.name || user.username} <Button
</span> variant="outline"
<DropdownMenu> className="relative h-10 w-10 rounded-full"
<DropdownMenuTrigger asChild>
<Button
variant="outline"
className="relative h-10 w-10 rounded-full"
>
<Avatar className="h-9 w-9">
<AvatarFallback>{getInitials()}</AvatarFallback>
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-56"
align="start"
forceMount
> >
<DropdownMenuLabel className="font-normal"> <Avatar className="h-9 w-9">
<div className="flex flex-col space-y-1"> <AvatarFallback>{getInitials()}</AvatarFallback>
<p className="text-sm font-medium leading-none"> </Avatar>
{t('signingAs')} </Button>
</p> </DropdownMenuTrigger>
<p className="text-xs leading-none text-muted-foreground"> <DropdownMenuContent className="w-56" align="start" forceMount>
{user.email || user.name || user.username} <DropdownMenuLabel className="font-normal">
</p> <div className="flex flex-col space-y-1">
</div> <p className="text-sm font-medium leading-none">
{user.serverAdmin ? ( {t("signingAs")}
<p className="text-xs leading-none text-muted-foreground mt-2"> </p>
{t('serverAdmin')} <p className="text-xs leading-none text-muted-foreground">
</p> {user.email || user.name || user.username}
) : ( </p>
<p className="text-xs leading-none text-muted-foreground mt-2"> </div>
{user.idpName || t('idpNameInternal')} {user.serverAdmin ? (
</p> <p className="text-xs leading-none text-muted-foreground mt-2">
)} {t("serverAdmin")}
</DropdownMenuLabel> </p>
<DropdownMenuSeparator /> ) : (
{user?.type === UserType.Internal && ( <p className="text-xs leading-none text-muted-foreground mt-2">
<> {user.idpName || t("idpNameInternal")}
{!user.twoFactorEnabled && ( </p>
<DropdownMenuItem
onClick={() => setOpenEnable2fa(true)}
>
<span>{t('otpEnable')}</span>
</DropdownMenuItem>
)}
{user.twoFactorEnabled && (
<DropdownMenuItem
onClick={() => setOpenDisable2fa(true)}
>
<span>{t('otpDisable')}</span>
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
</>
)} )}
<DropdownMenuLabel>{t("theme")}</DropdownMenuLabel> </DropdownMenuLabel>
{(["light", "dark", "system"] as const).map( <DropdownMenuSeparator />
(themeOption) => ( {user?.type === UserType.Internal && (
<>
{!user.twoFactorEnabled && (
<DropdownMenuItem <DropdownMenuItem
key={themeOption} onClick={() => setOpenEnable2fa(true)}
onClick={() =>
handleThemeChange(themeOption)
}
> >
{themeOption === "light" && ( <span>{t("otpEnable")}</span>
<Sun className="mr-2 h-4 w-4" />
)}
{themeOption === "dark" && (
<Moon className="mr-2 h-4 w-4" />
)}
{themeOption === "system" && (
<Laptop className="mr-2 h-4 w-4" />
)}
<span className="capitalize">
{t(themeOption)}
</span>
{userTheme === themeOption && (
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<span className="h-2 w-2 rounded-full bg-primary"></span>
</span>
)}
</DropdownMenuItem> </DropdownMenuItem>
) )}
)} {user.twoFactorEnabled && (
<DropdownMenuSeparator /> <DropdownMenuItem
<LocaleSwitcher /> onClick={() => setOpenDisable2fa(true)}
<DropdownMenuSeparator /> >
<DropdownMenuItem onClick={() => logout()}> <span>{t("otpDisable")}</span>
{/* <LogOut className="mr-2 h-4 w-4" /> */} </DropdownMenuItem>
<span>{t('logout')}</span> )}
</DropdownMenuItem> <DropdownMenuSeparator />
</DropdownMenuContent> </>
</DropdownMenu> )}
</div> <DropdownMenuLabel>{t("theme")}</DropdownMenuLabel>
{(["light", "dark", "system"] as const).map(
(themeOption) => (
<DropdownMenuItem
key={themeOption}
onClick={() => handleThemeChange(themeOption)}
>
{themeOption === "light" && (
<Sun className="mr-2 h-4 w-4" />
)}
{themeOption === "dark" && (
<Moon className="mr-2 h-4 w-4" />
)}
{themeOption === "system" && (
<Laptop className="mr-2 h-4 w-4" />
)}
<span className="capitalize">
{t(themeOption)}
</span>
{userTheme === themeOption && (
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<span className="h-2 w-2 rounded-full bg-primary"></span>
</span>
)}
</DropdownMenuItem>
)
)}
<DropdownMenuSeparator />
<LocaleSwitcher />
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => logout()}>
{/* <LogOut className="mr-2 h-4 w-4" /> */}
<span>{t("logout")}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</> </>
); );
} }

View file

@ -1,35 +1,45 @@
"use client"; "use client";
import React, { useState, useEffect } from "react"; import React from "react";
import Link from "next/link"; import Link from "next/link";
import { useParams, usePathname } from "next/navigation"; import { useParams, usePathname } from "next/navigation";
import { cn } from "@app/lib/cn"; import { cn } from "@app/lib/cn";
import { ChevronDown, ChevronRight } from "lucide-react";
import { useUserContext } from "@app/hooks/useUserContext"; import { useUserContext } from "@app/hooks/useUserContext";
import { Badge } from "@app/components/ui/badge"; import { Badge } from "@app/components/ui/badge";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext"; import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from "@app/components/ui/tooltip";
export interface SidebarNavItem { export type SidebarNavItem = {
href: string; href: string;
title: string; title: string;
icon?: React.ReactNode; icon?: React.ReactNode;
children?: SidebarNavItem[];
autoExpand?: boolean;
showProfessional?: boolean; showProfessional?: boolean;
} };
export type SidebarNavSection = {
heading: string;
items: SidebarNavItem[];
};
export interface SidebarNavProps extends React.HTMLAttributes<HTMLElement> { export interface SidebarNavProps extends React.HTMLAttributes<HTMLElement> {
items: SidebarNavItem[]; sections: SidebarNavSection[];
disabled?: boolean; disabled?: boolean;
onItemClick?: () => void; onItemClick?: () => void;
isCollapsed?: boolean;
} }
export function SidebarNav({ export function SidebarNav({
className, className,
items, sections,
disabled = false, disabled = false,
onItemClick, onItemClick,
isCollapsed = false,
...props ...props
}: SidebarNavProps) { }: SidebarNavProps) {
const pathname = usePathname(); const pathname = usePathname();
@ -39,34 +49,8 @@ export function SidebarNav({
const resourceId = params.resourceId as string; const resourceId = params.resourceId as string;
const userId = params.userId as string; const userId = params.userId as string;
const clientId = params.clientId as string; const clientId = params.clientId as string;
const [expandedItems, setExpandedItems] = useState<Set<string>>(() => {
const autoExpanded = new Set<string>();
function findAutoExpandedAndActivePath(
items: SidebarNavItem[],
parentHrefs: string[] = []
) {
items.forEach((item) => {
const hydratedHref = hydrateHref(item.href);
const currentPath = [...parentHrefs, hydratedHref];
if (item.autoExpand || pathname.startsWith(hydratedHref)) {
currentPath.forEach((href) => autoExpanded.add(href));
}
if (item.children) {
findAutoExpandedAndActivePath(item.children, currentPath);
}
});
}
findAutoExpandedAndActivePath(items);
return autoExpanded;
});
const { licenseStatus, isUnlocked } = useLicenseStatusContext(); const { licenseStatus, isUnlocked } = useLicenseStatusContext();
const { user } = useUserContext(); const { user } = useUserContext();
const t = useTranslations(); const t = useTranslations();
function hydrateHref(val: string): string { function hydrateHref(val: string): string {
@ -78,116 +62,109 @@ export function SidebarNav({
.replace("{clientId}", clientId); .replace("{clientId}", clientId);
} }
function toggleItem(href: string) { const renderNavItem = (
setExpandedItems((prev) => { item: SidebarNavItem,
const newSet = new Set(prev); hydratedHref: string,
if (newSet.has(href)) { isActive: boolean,
newSet.delete(href); isDisabled: boolean
} else { ) => {
newSet.add(href); const tooltipText =
} item.showProfessional && !isUnlocked()
return newSet; ? `${t(item.title)} (${t("licenseBadge")})`
}); : t(item.title);
}
function renderItems(items: SidebarNavItem[], level = 0) { const itemContent = (
return items.map((item) => { <Link
const hydratedHref = hydrateHref(item.href); href={isDisabled ? "#" : hydratedHref}
const isActive = pathname.startsWith(hydratedHref); className={cn(
const hasChildren = item.children && item.children.length > 0; "flex items-center rounded transition-colors hover:bg-secondary/50 dark:hover:bg-secondary/20 rounded-md",
const isExpanded = expandedItems.has(hydratedHref); isCollapsed ? "px-2 py-2 justify-center" : "px-3 py-1.5",
const indent = level * 28; // Base indent for each level isActive
const isProfessional = item.showProfessional && !isUnlocked(); ? "text-primary font-medium"
const isDisabled = disabled || isProfessional; : "text-muted-foreground hover:text-foreground",
isDisabled && "cursor-not-allowed opacity-60"
return ( )}
<div key={hydratedHref}> onClick={(e) => {
<div if (isDisabled) {
className="flex items-center group" e.preventDefault();
style={{ marginLeft: `${indent}px` }} } else if (onItemClick) {
onItemClick();
}
}}
tabIndex={isDisabled ? -1 : undefined}
aria-disabled={isDisabled}
>
{item.icon && (
<span
className={cn("flex-shrink-0", !isCollapsed && "mr-2")}
> >
<div {item.icon}
className={cn( </span>
"flex items-center w-full transition-colors rounded-md", )}
isActive && level === 0 && "bg-primary/10" {!isCollapsed && (
)} <>
> <span>{t(item.title)}</span>
<Link {item.showProfessional && !isUnlocked() && (
href={isProfessional ? "#" : hydratedHref} <Badge variant="outlinePrimary" className="ml-2">
className={cn( {t("licenseBadge")}
"flex items-center w-full px-3 py-2", </Badge>
isActive )}
? "text-primary font-medium" </>
: "text-muted-foreground group-hover:text-foreground", )}
isDisabled && "cursor-not-allowed" </Link>
)} );
onClick={(e) => {
if (isDisabled) { if (isCollapsed) {
e.preventDefault(); return (
} else if (onItemClick) { <TooltipProvider key={hydratedHref}>
onItemClick(); <Tooltip>
} <TooltipTrigger asChild>{itemContent}</TooltipTrigger>
}} <TooltipContent side="right" sideOffset={8}>
tabIndex={isDisabled ? -1 : undefined} <p>{tooltipText}</p>
aria-disabled={isDisabled} </TooltipContent>
> </Tooltip>
<div </TooltipProvider>
className={cn(
"flex items-center",
isDisabled && "opacity-60"
)}
>
{item.icon && (
<span className="mr-3">
{item.icon}
</span>
)}
{t(item.title)}
</div>
{isProfessional && (
<Badge
variant="outlinePrimary"
className="ml-2"
>
{t('licenseBadge')}
</Badge>
)}
</Link>
{hasChildren && (
<button
onClick={() => toggleItem(hydratedHref)}
className="p-2 rounded-md text-muted-foreground hover:text-foreground cursor-pointer"
disabled={isDisabled}
>
{isExpanded ? (
<ChevronDown className="h-5 w-5" />
) : (
<ChevronRight className="h-5 w-5" />
)}
</button>
)}
</div>
</div>
{hasChildren && isExpanded && (
<div className="space-y-1 mt-1">
{renderItems(item.children || [], level + 1)}
</div>
)}
</div>
); );
}); }
}
return (
<React.Fragment key={hydratedHref}>{itemContent}</React.Fragment>
);
};
return ( return (
<nav <nav
className={cn( className={cn(
"flex flex-col space-y-2", "flex flex-col gap-2 text-sm",
disabled && "pointer-events-none opacity-60", disabled && "pointer-events-none opacity-60",
className className
)} )}
{...props} {...props}
> >
{renderItems(items)} {sections.map((section) => (
<div key={section.heading} className="mb-2">
{!isCollapsed && (
<div className="px-3 py-1 text-xs font-semibold text-muted-foreground uppercase tracking-wide">
{section.heading}
</div>
)}
<div className="flex flex-col gap-1">
{section.items.map((item) => {
const hydratedHref = hydrateHref(item.href);
const isActive = pathname.startsWith(hydratedHref);
const isProfessional =
item.showProfessional && !isUnlocked();
const isDisabled = disabled || isProfessional;
return renderNavItem(
item,
hydratedHref,
isActive,
isDisabled || false
);
})}
</div>
</div>
))}
</nav> </nav>
); );
} }

View file

@ -1,35 +0,0 @@
"use client";
import { SidebarNav } from "@app/components/SidebarNav";
import React from "react";
interface SideBarSettingsProps {
children: React.ReactNode;
sidebarNavItems: Array<{
title: string;
href: string;
icon?: React.ReactNode;
}>;
disabled?: boolean;
limitWidth?: boolean;
}
export function SidebarSettings({
children,
sidebarNavItems,
disabled,
limitWidth
}: SideBarSettingsProps) {
return (
<div className="space-y-4">
<div className="flex flex-col space-y-4 lg:flex-row lg:space-x-6 lg:space-y-0">
<aside className="lg:w-1/5">
<SidebarNav items={sidebarNavItems} disabled={disabled} />
</aside>
<div className={`flex-1 ${limitWidth ? "lg:max-w-2xl" : ""} space-y-6`}>
{children}
</div>
</div>
</div>
);
}

View file

@ -9,6 +9,12 @@ import {
PopoverContent, PopoverContent,
PopoverTrigger PopoverTrigger
} from "@app/components/ui/popover"; } from "@app/components/ui/popover";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@app/components/ui/tooltip";
import { Button } from "./ui/button"; import { Button } from "./ui/button";
import { import {
Credenza, Credenza,
@ -46,11 +52,15 @@ import {
CardHeader, CardHeader,
CardTitle CardTitle
} from "./ui/card"; } from "./ui/card";
import { Check, ExternalLink } from "lucide-react"; import { Check, ExternalLink, Heart } from "lucide-react";
import confetti from "canvas-confetti"; import confetti from "canvas-confetti";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
export default function SupporterStatus() { interface SupporterStatusProps {
isCollapsed?: boolean;
}
export default function SupporterStatus({ isCollapsed = false }: SupporterStatusProps) {
const { supporterStatus, updateSupporterStatus } = const { supporterStatus, updateSupporterStatus } =
useSupporterStatusContext(); useSupporterStatusContext();
const [supportOpen, setSupportOpen] = useState(false); const [supportOpen, setSupportOpen] = useState(false);
@ -411,16 +421,36 @@ export default function SupporterStatus() {
</Credenza> </Credenza>
{supporterStatus?.visible ? ( {supporterStatus?.visible ? (
<Button isCollapsed ? (
variant="outlinePrimary" <TooltipProvider>
size="sm" <Tooltip>
className="gap-2 w-full" <TooltipTrigger asChild>
onClick={() => { <Button
setPurchaseOptionsOpen(true); size="icon"
}} className="w-8 h-8"
> onClick={() => {
{t('supportKeyBuy')} setPurchaseOptionsOpen(true);
</Button> }}
>
<Heart className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
<p>{t('supportKeyBuy')}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<Button
size="sm"
className="gap-2 w-full"
onClick={() => {
setPurchaseOptionsOpen(true);
}}
>
{t('supportKeyBuy')}
</Button>
)
) : null} ) : null}
</> </>
); );

View file

@ -0,0 +1,78 @@
"use client";
import { Button } from "@app/components/ui/button";
import { Laptop, Moon, Sun } from "lucide-react";
import { useTranslations } from "next-intl";
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
export default function ThemeSwitcher() {
const { setTheme, theme, resolvedTheme } = useTheme();
const [mounted, setMounted] = useState(false);
const t = useTranslations();
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return (
<Button variant="ghost" size="sm" className="h-8">
<Sun className="h-4 w-4 mr-2" />
Light
</Button>
);
}
function cycleTheme() {
const currentTheme = theme || "system";
if (currentTheme === "light") {
setTheme("dark");
} else if (currentTheme === "dark") {
setTheme("system");
} else {
setTheme("light");
}
}
function getThemeIcon() {
const currentTheme = theme || "system";
if (currentTheme === "light") {
return <Sun className="h-4 w-4" />;
} else if (currentTheme === "dark") {
return <Moon className="h-4 w-4" />;
} else {
// When theme is "system", show icon based on resolved theme
if (resolvedTheme === "light") {
return <Sun className="h-4 w-4" />;
} else if (resolvedTheme === "dark") {
return <Moon className="h-4 w-4" />;
} else {
// Fallback to laptop icon if resolvedTheme is not available
return <Laptop className="h-4 w-4" />;
}
}
}
function getThemeText() {
const currentTheme = theme || "system";
const translated = t(currentTheme);
return translated.charAt(0).toUpperCase() + translated.slice(1);
}
return (
<Button
variant="ghost"
size="sm"
className="h-8"
onClick={cycleTheme}
title={`Current theme: ${theme || "system"}. Click to cycle themes.`}
>
{getThemeIcon()}
<span className="ml-2">{getThemeText()}</span>
</Button>
);
}

View file

@ -173,7 +173,7 @@ const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
(maxTags !== undefined && maxTags < 0) || (maxTags !== undefined && maxTags < 0) ||
(props.minTags !== undefined && props.minTags < 0) (props.minTags !== undefined && props.minTags < 0)
) { ) {
console.warn(t('tagsWarnCannotBeLessThanZero')); console.warn(t("tagsWarnCannotBeLessThanZero"));
// error // error
return null; return null;
} }
@ -197,22 +197,28 @@ const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
(option) => option.text === newTagText (option) => option.text === newTagText
) )
) { ) {
console.warn(t('tagsWarnNotAllowedAutocompleteOptions')); console.warn(
t("tagsWarnNotAllowedAutocompleteOptions")
);
return; return;
} }
if (validateTag && !validateTag(newTagText)) { if (validateTag && !validateTag(newTagText)) {
console.warn(t('tagsWarnInvalid')); console.warn(t("tagsWarnInvalid"));
return; return;
} }
if (minLength && newTagText.length < minLength) { if (minLength && newTagText.length < minLength) {
console.warn(t('tagWarnTooShort', {tagText: newTagText})); console.warn(
t("tagWarnTooShort", { tagText: newTagText })
);
return; return;
} }
if (maxLength && newTagText.length > maxLength) { if (maxLength && newTagText.length > maxLength) {
console.warn(t('tagWarnTooLong', {tagText: newTagText})); console.warn(
t("tagWarnTooLong", { tagText: newTagText })
);
return; return;
} }
@ -229,10 +235,12 @@ const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
setTags((prevTags) => [...prevTags, newTag]); setTags((prevTags) => [...prevTags, newTag]);
onTagAdd?.(newTagText); onTagAdd?.(newTagText);
} else { } else {
console.warn(t('tagsWarnReachedMaxNumber')); console.warn(t("tagsWarnReachedMaxNumber"));
} }
} else { } else {
console.warn(t('tagWarnDuplicate', {tagText: newTagText})); console.warn(
t("tagWarnDuplicate", { tagText: newTagText })
);
} }
}); });
setInputValue(""); setInputValue("");
@ -258,12 +266,12 @@ const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
} }
if (minLength && newTagText.length < minLength) { if (minLength && newTagText.length < minLength) {
console.warn(t('tagWarnTooShort')); console.warn(t("tagWarnTooShort"));
return; return;
} }
if (maxLength && newTagText.length > maxLength) { if (maxLength && newTagText.length > maxLength) {
console.warn(t('tagWarnTooLong')); console.warn(t("tagWarnTooLong"));
return; return;
} }
@ -308,7 +316,7 @@ const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
} }
if (minLength && newTagText.length < minLength) { if (minLength && newTagText.length < minLength) {
console.warn(t('tagWarnTooShort')); console.warn(t("tagWarnTooShort"));
// error // error
return; return;
} }
@ -316,7 +324,7 @@ const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
// Validate maxLength // Validate maxLength
if (maxLength && newTagText.length > maxLength) { if (maxLength && newTagText.length > maxLength) {
// error // error
console.warn(t('tagWarnTooLong')); console.warn(t("tagWarnTooLong"));
return; return;
} }
@ -489,7 +497,7 @@ const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
<div className="w-full"> <div className="w-full">
<div <div
className={cn( className={cn(
`flex flex-row flex-wrap items-center gap-1.5 p-1.5 w-full rounded-md border border-input bg-background text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50 bg-transparent`, `flex flex-row flex-wrap items-center gap-1.5 p-1.5 w-full rounded-md border border-input bg-background text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50`,
styleClasses?.inlineTagsContainer styleClasses?.inlineTagsContainer
)} )}
> >
@ -536,7 +544,7 @@ const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
onBlur={handleInputBlur} onBlur={handleInputBlur}
{...inputProps} {...inputProps}
className={cn( className={cn(
"border-0 h-5 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit", "border-0 h-5 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit shadow-none inset-shadow-none",
// className, // className,
styleClasses?.input styleClasses?.input
)} )}
@ -622,7 +630,7 @@ const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
onBlur={handleInputBlur} onBlur={handleInputBlur}
{...inputProps} {...inputProps}
className={cn( className={cn(
"border-0 h-5 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit", "border-0 h-5 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit shadow-none inset-shadow-none",
// className, // className,
styleClasses?.input styleClasses?.input
)} )}
@ -710,7 +718,7 @@ const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
onBlur={handleInputBlur} onBlur={handleInputBlur}
{...inputProps} {...inputProps}
className={cn( className={cn(
"border-0 h-5 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit", "border-0 h-5 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit shadow-none inset-shadow-none",
// className, // className,
styleClasses?.input styleClasses?.input
)} )}
@ -791,7 +799,7 @@ const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
onBlur={handleInputBlur} onBlur={handleInputBlur}
{...inputProps} {...inputProps}
className={cn( className={cn(
"border-0 h-5 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit", "border-0 h-5 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit shadow-none inset-shadow-none",
// className, // className,
styleClasses?.input styleClasses?.input
)} )}
@ -834,7 +842,8 @@ const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
onBlur={handleInputBlur} onBlur={handleInputBlur}
{...inputProps} {...inputProps}
className={cn( className={cn(
styleClasses?.input styleClasses?.input,
"shadow-none inset-shadow-none"
// className // className
)} )}
autoComplete={ autoComplete={
@ -908,7 +917,7 @@ const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
tags.length >= maxTags) tags.length >= maxTags)
} }
className={cn( className={cn(
"border-0 w-full", "border-0 w-full shadow-none inset-shadow-none",
styleClasses?.input styleClasses?.input
// className // className
)} )}

View file

@ -14,14 +14,13 @@ const alertVariants = cva(
"border-destructive/50 border bg-destructive/10 text-destructive dark:border-destructive [&>svg]:text-destructive", "border-destructive/50 border bg-destructive/10 text-destructive dark:border-destructive [&>svg]:text-destructive",
success: success:
"border-green-500/50 border bg-green-500/10 text-green-500 dark:border-success [&>svg]:text-green-500", "border-green-500/50 border bg-green-500/10 text-green-500 dark:border-success [&>svg]:text-green-500",
info: info: "border-blue-500/50 border bg-blue-500/10 text-blue-500 dark:border-blue-400 [&>svg]:text-blue-500"
"border-blue-500/50 border bg-blue-500/10 text-blue-500 dark:border-blue-400 [&>svg]:text-blue-500", }
},
}, },
defaultVariants: { defaultVariants: {
variant: "default", variant: "default"
}, }
}, }
); );
const Alert = React.forwardRef< const Alert = React.forwardRef<
@ -45,7 +44,7 @@ const AlertTitle = React.forwardRef<
ref={ref} ref={ref}
className={cn( className={cn(
"mb-1 font-medium leading-none tracking-tight", "mb-1 font-medium leading-none tracking-tight",
className, className
)} )}
{...props} {...props}
/> />

View file

@ -6,35 +6,35 @@ import { cn } from "@app/lib/cn";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
const buttonVariants = cva( const buttonVariants = cva(
"cursor-pointer inline-flex items-center justify-center rounded-full whitespace-nowrap text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-0 disabled:pointer-events-none disabled:opacity-50", "cursor-pointer inline-flex items-center justify-center whitespace-nowrap text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-0 disabled:pointer-events-none disabled:opacity-50",
{ {
variants: { variants: {
variant: { variant: {
default: default:
"bg-primary text-primary-foreground hover:bg-primary/90", "bg-primary text-primary-foreground hover:bg-primary/90 shadow-2xs",
destructive: destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90", "bg-destructive text-white dark:text-destructive-foreground hover:bg-destructive/90 shadow-2xs",
outline: outline:
"border border-input bg-card hover:bg-accent hover:text-accent-foreground", "border border-input bg-card hover:bg-accent hover:text-accent-foreground shadow-2xs",
outlinePrimary: outlinePrimary:
"border border-primary bg-card hover:bg-primary/10 text-primary", "border border-primary bg-card hover:bg-primary/10 text-primary shadow-2xs",
secondary: secondary:
"bg-secondary border border-input border text-secondary-foreground hover:bg-secondary/80", "bg-secondary border border-input border text-secondary-foreground hover:bg-secondary/80 shadow-2xs",
ghost: "hover:bg-accent hover:text-accent-foreground", ghost: "hover:bg-accent hover:text-accent-foreground",
squareOutlinePrimary: squareOutlinePrimary:
"border border-primary bg-card hover:bg-primary/10 text-primary rounded-md", "border border-primary bg-card hover:bg-primary/10 text-primary rounded-md shadow-2xs",
squareOutline: squareOutline:
"border border-input bg-card hover:bg-accent hover:text-accent-foreground rounded-md", "border border-input bg-card hover:bg-accent hover:text-accent-foreground rounded-md shadow-2xs",
squareDefault: squareDefault:
"bg-primary text-primary-foreground hover:bg-primary/90 rounded-md", "bg-primary text-primary-foreground hover:bg-primary/90 rounded-md shadow-2xs",
text: "", text: "",
link: "text-primary underline-offset-4 hover:underline" link: "text-primary underline-offset-4 hover:underline"
}, },
size: { size: {
default: "h-9 px-4 py-2", default: "h-9 rounded-md px-3",
sm: "h-8 rounded-md px-3", sm: "h-8 rounded-md px-3",
lg: "h-10 rounded-md px-8", lg: "h-10 rounded-md px-8",
icon: "h-9 w-9" icon: "h-9 w-9 rounded-md"
} }
}, },
defaultVariants: { defaultVariants: {

View file

@ -1,155 +1,183 @@
"use client"; "use client";
import * as React from "react"; import * as React from "react";
import { type DialogProps } from "@radix-ui/react-dialog";
import { Command as CommandPrimitive } from "cmdk"; import { Command as CommandPrimitive } from "cmdk";
import { Search } from "lucide-react"; import { SearchIcon } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle
} from "@/components/ui/dialog";
import { cn } from "@app/lib/cn"; import { cn } from "@app/lib/cn";
import { Dialog, DialogContent } from "@/components/ui/dialog";
const Command = React.forwardRef< function Command({
React.ElementRef<typeof CommandPrimitive>, className,
React.ComponentPropsWithoutRef<typeof CommandPrimitive> ...props
>(({ className, ...props }, ref) => ( }: React.ComponentProps<typeof CommandPrimitive>) {
<CommandPrimitive return (
ref={ref} <CommandPrimitive
className={cn( data-slot="command"
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground", className={cn(
className "bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
)} className
{...props} )}
/> {...props}
)); />
Command.displayName = CommandPrimitive.displayName; );
}
interface CommandDialogProps extends DialogProps {} function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
className,
showCloseButton = true,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string;
description?: string;
className?: string;
showCloseButton?: boolean;
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn("overflow-hidden p-0", className)}
>
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
);
}
const CommandDialog = ({ children, ...props }: CommandDialogProps) => { function CommandInput({
return ( className,
<Dialog {...props}> ...props
<DialogContent className="overflow-hidden p-0 shadow-lg"> }: React.ComponentProps<typeof CommandPrimitive.Input>) {
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5"> return (
{children} <div
</Command> data-slot="command-input-wrapper"
</DialogContent> className="flex h-9 items-center gap-2 border-b px-3"
</Dialog> >
); <SearchIcon className="size-4 shrink-0 opacity-50" />
}; <CommandPrimitive.Input
data-slot="command-input"
className={cn(
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
);
}
const CommandInput = React.forwardRef< function CommandList({
React.ElementRef<typeof CommandPrimitive.Input>, className,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input> ...props
>(({ className, ...props }, ref) => ( }: React.ComponentProps<typeof CommandPrimitive.List>) {
<div className="flex items-center border-b px-3" cmdk-input-wrapper=""> return (
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" /> <CommandPrimitive.List
<CommandPrimitive.Input data-slot="command-list"
ref={ref} className={cn(
className={cn( "max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
"flex h-11 w-full rounded-md bg-transparent py-3 text-base md:text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50", className
className )}
)} {...props}
{...props} />
/> );
</div> }
));
CommandInput.displayName = CommandPrimitive.Input.displayName; function CommandEmpty({
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className="py-6 text-center text-sm"
{...props}
/>
);
}
const CommandList = React.forwardRef< function CommandGroup({
React.ElementRef<typeof CommandPrimitive.List>, className,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List> ...props
>(({ className, ...props }, ref) => ( }: React.ComponentProps<typeof CommandPrimitive.Group>) {
<CommandPrimitive.List return (
ref={ref} <CommandPrimitive.Group
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)} data-slot="command-group"
{...props} className={cn(
/> "text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
)); className
)}
{...props}
/>
);
}
CommandList.displayName = CommandPrimitive.List.displayName; function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("bg-border -mx-1 h-px", className)}
{...props}
/>
);
}
const CommandEmpty = React.forwardRef< function CommandItem({
React.ElementRef<typeof CommandPrimitive.Empty>, className,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty> ...props
>((props, ref) => ( }: React.ComponentProps<typeof CommandPrimitive.Item>) {
<CommandPrimitive.Empty return (
ref={ref} <CommandPrimitive.Item
className="py-6 text-center text-sm" data-slot="command-item"
{...props} className={cn(
/> "data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
)); className
)}
{...props}
/>
);
}
CommandEmpty.displayName = CommandPrimitive.Empty.displayName; function CommandShortcut({
className,
const CommandGroup = React.forwardRef< ...props
React.ElementRef<typeof CommandPrimitive.Group>, }: React.ComponentProps<"span">) {
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group> return (
>(({ className, ...props }, ref) => ( <span
<CommandPrimitive.Group data-slot="command-shortcut"
ref={ref} className={cn(
className={cn( "text-muted-foreground ml-auto text-xs tracking-widest",
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground", className
className )}
)} {...props}
{...props} />
/> );
)); }
CommandGroup.displayName = CommandPrimitive.Group.displayName;
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
));
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none rounded-md items-center px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected='true']:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50",
className,
)}
{...props}
/>
));
CommandItem.displayName = CommandPrimitive.Item.displayName;
const CommandShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
);
};
CommandShortcut.displayName = "CommandShortcut";
export { export {
Command, Command,
CommandDialog, CommandDialog,
CommandInput, CommandInput,
CommandList, CommandList,
CommandEmpty, CommandEmpty,
CommandGroup, CommandGroup,
CommandItem, CommandItem,
CommandShortcut, CommandShortcut,
CommandSeparator, CommandSeparator
}; };

View file

@ -93,8 +93,8 @@ export function DataTable<TData, TValue>({
return ( return (
<div className="container mx-auto max-w-12xl"> <div className="container mx-auto max-w-12xl">
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-4"> <CardHeader className="flex flex-col space-y-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0 pb-4">
<div className="flex items-center max-w-sm w-full relative mr-2"> <div className="flex items-center w-full sm:max-w-sm sm:mr-2 relative">
<Input <Input
placeholder={searchPlaceholder} placeholder={searchPlaceholder}
value={globalFilter ?? ""} value={globalFilter ?? ""}
@ -105,7 +105,7 @@ export function DataTable<TData, TValue>({
/> />
<Search className="h-4 w-4 absolute left-2 top-1/2 transform -translate-y-1/2 text-muted-foreground" /> <Search className="h-4 w-4 absolute left-2 top-1/2 transform -translate-y-1/2 text-muted-foreground" />
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 sm:justify-end">
{onRefresh && ( {onRefresh && (
<Button <Button
variant="outline" variant="outline"

View file

@ -38,7 +38,7 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content <DialogPrimitive.Content
ref={ref} ref={ref}
className={cn( className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-card p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-99 data-[state=open]:zoom-in-99 data-[state=closed]:slide-out-to-bottom-[5%] data-[state=open]:slide-in-from-bottom-[5%] sm:rounded-lg", "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-card p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:scale-95 data-[state=open]:scale-100 sm:rounded-lg",
className className
)} )}
{...props} {...props}

View file

@ -2,199 +2,263 @@
import * as React from "react"; import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { Check, ChevronRight, Circle } from "lucide-react"; import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import { cn } from "@app/lib/cn"; import { cn } from "@app/lib/cn";
const DropdownMenu = DropdownMenuPrimitive.Root; function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
}
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal
data-slot="dropdown-menu-portal"
{...props}
/>
);
}
const DropdownMenuGroup = DropdownMenuPrimitive.Group; function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
);
}
const DropdownMenuPortal = DropdownMenuPrimitive.Portal; function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
);
}
const DropdownMenuSub = DropdownMenuPrimitive.Sub; function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group
data-slot="dropdown-menu-group"
{...props}
/>
);
}
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
);
}
const DropdownMenuSubTrigger = React.forwardRef< function DropdownMenuCheckboxItem({
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>, className,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & { children,
inset?: boolean checked,
} ...props
>(({ className, inset, children, ...props }, ref) => ( }: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
<DropdownMenuPrimitive.SubTrigger return (
ref={ref} <DropdownMenuPrimitive.CheckboxItem
className={cn( data-slot="dropdown-menu-checkbox-item"
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent", className={cn(
inset && "pl-8", "focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className className
)} )}
{...props} checked={checked}
> {...props}
{children} >
<ChevronRight className="ml-auto h-4 w-4" /> <span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
</DropdownMenuPrimitive.SubTrigger> <DropdownMenuPrimitive.ItemIndicator>
)); <CheckIcon className="size-4" />
DropdownMenuSubTrigger.displayName = </DropdownMenuPrimitive.ItemIndicator>
DropdownMenuPrimitive.SubTrigger.displayName; </span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
);
}
const DropdownMenuSubContent = React.forwardRef< function DropdownMenuRadioGroup({
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>, ...props
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent> }: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
>(({ className, ...props }, ref) => ( return (
<DropdownMenuPrimitive.SubContent <DropdownMenuPrimitive.RadioGroup
ref={ref} data-slot="dropdown-menu-radio-group"
className={cn( {...props}
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", />
className );
)} }
{...props}
/>
));
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef< function DropdownMenuRadioItem({
React.ElementRef<typeof DropdownMenuPrimitive.Content>, className,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content> children,
>(({ className, sideOffset = 4, ...props }, ref) => ( ...props
<DropdownMenuPrimitive.Portal> }: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
<DropdownMenuPrimitive.Content return (
ref={ref} <DropdownMenuPrimitive.RadioItem
sideOffset={sideOffset} data-slot="dropdown-menu-radio-item"
className={cn( className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", "focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className className
)} )}
{...props} {...props}
/> >
</DropdownMenuPrimitive.Portal> <span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
)); <DropdownMenuPrimitive.ItemIndicator>
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; <CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
);
}
const DropdownMenuItem = React.forwardRef< function DropdownMenuLabel({
React.ElementRef<typeof DropdownMenuPrimitive.Item>, className,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & { inset,
inset?: boolean ...props
} }: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
>(({ className, inset, ...props }, ref) => ( inset?: boolean;
<DropdownMenuPrimitive.Item }) {
ref={ref} return (
className={cn( <DropdownMenuPrimitive.Label
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", data-slot="dropdown-menu-label"
inset && "pl-8", data-inset={inset}
className className={cn(
)} "px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
{...props} className
/> )}
)); {...props}
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; />
);
}
const DropdownMenuCheckboxItem = React.forwardRef< function DropdownMenuSeparator({
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>, className,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem> ...props
>(({ className, children, checked, ...props }, ref) => ( }: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
<DropdownMenuPrimitive.CheckboxItem return (
ref={ref} <DropdownMenuPrimitive.Separator
className={cn( data-slot="dropdown-menu-separator"
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", className={cn("bg-border -mx-1 my-1 h-px", className)}
className {...props}
)} />
checked={checked} );
{...props} }
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef< function DropdownMenuShortcut({
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>, className,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem> ...props
>(({ className, children, ...props }, ref) => ( }: React.ComponentProps<"span">) {
<DropdownMenuPrimitive.RadioItem return (
ref={ref} <span
className={cn( data-slot="dropdown-menu-shortcut"
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", className={cn(
className "text-muted-foreground ml-auto text-xs tracking-widest",
)} className
{...props} )}
> {...props}
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> />
<DropdownMenuPrimitive.ItemIndicator> );
<Circle className="h-2 w-2 fill-current" /> }
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef< function DropdownMenuSub({
React.ElementRef<typeof DropdownMenuPrimitive.Label>, ...props
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & { }: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
inset?: boolean return (
} <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
>(({ className, inset, ...props }, ref) => ( );
<DropdownMenuPrimitive.Label }
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef< function DropdownMenuSubTrigger({
React.ElementRef<typeof DropdownMenuPrimitive.Separator>, className,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator> inset,
>(({ className, ...props }, ref) => ( children,
<DropdownMenuPrimitive.Separator ...props
ref={ref} }: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
className={cn("-mx-1 my-1 h-px bg-muted", className)} inset?: boolean;
{...props} }) {
/> return (
)); <DropdownMenuPrimitive.SubTrigger
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
);
}
const DropdownMenuShortcut = ({ function DropdownMenuSubContent({
className, className,
...props ...props
}: React.HTMLAttributes<HTMLSpanElement>) => { }: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return ( return (
<span <DropdownMenuPrimitive.SubContent
className={cn("ml-auto text-xs tracking-widest opacity-60", className)} data-slot="dropdown-menu-sub-content"
{...props} className={cn(
/> "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
); className
}; )}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"; {...props}
/>
);
}
export { export {
DropdownMenu, DropdownMenu,
DropdownMenuTrigger, DropdownMenuPortal,
DropdownMenuContent, DropdownMenuTrigger,
DropdownMenuItem, DropdownMenuContent,
DropdownMenuCheckboxItem, DropdownMenuGroup,
DropdownMenuRadioItem, DropdownMenuLabel,
DropdownMenuLabel, DropdownMenuItem,
DropdownMenuSeparator, DropdownMenuCheckboxItem,
DropdownMenuShortcut, DropdownMenuRadioGroup,
DropdownMenuGroup, DropdownMenuRadioItem,
DropdownMenuPortal, DropdownMenuSeparator,
DropdownMenuSub, DropdownMenuShortcut,
DropdownMenuSubContent, DropdownMenuSub,
DropdownMenuSubTrigger, DropdownMenuSubTrigger,
DropdownMenuRadioGroup, DropdownMenuSubContent
}; };

View file

@ -5,15 +5,16 @@ import * as LabelPrimitive from "@radix-ui/react-label";
import { Slot } from "@radix-ui/react-slot"; import { Slot } from "@radix-ui/react-slot";
import { import {
Controller, Controller,
ControllerProps,
FieldPath,
FieldValues,
FormProvider, FormProvider,
useFormContext useFormContext,
useFormState,
type ControllerProps,
type FieldPath,
type FieldValues
} from "react-hook-form"; } from "react-hook-form";
import { cn } from "@app/lib/cn";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { cn } from "@app/lib/cn";
const Form = FormProvider; const Form = FormProvider;
@ -44,8 +45,8 @@ const FormField = <
const useFormField = () => { const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext); const fieldContext = React.useContext(FormFieldContext);
const itemContext = React.useContext(FormItemContext); const itemContext = React.useContext(FormItemContext);
const { getFieldState, formState } = useFormContext(); const { getFieldState } = useFormContext();
const formState = useFormState({ name: fieldContext.name });
const fieldState = getFieldState(fieldContext.name, formState); const fieldState = getFieldState(fieldContext.name, formState);
if (!fieldContext) { if (!fieldContext) {
@ -72,47 +73,44 @@ const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue {} as FormItemContextValue
); );
const FormItem = React.forwardRef< function FormItem({ className, ...props }: React.ComponentProps<"div">) {
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const id = React.useId(); const id = React.useId();
return ( return (
<FormItemContext.Provider value={{ id }}> <FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn("space-y-2", className)} {...props} /> <div
data-slot="form-item"
className={cn("grid gap-2", className)}
{...props}
/>
</FormItemContext.Provider> </FormItemContext.Provider>
); );
}); }
FormItem.displayName = "FormItem";
const FormLabel = React.forwardRef< function FormLabel({
React.ElementRef<typeof LabelPrimitive.Root>, className,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> ...props
>(({ className, ...props }, ref) => { }: React.ComponentProps<typeof LabelPrimitive.Root>) {
const { error, formItemId } = useFormField(); const { error, formItemId } = useFormField();
return ( return (
<Label <Label
ref={ref} data-slot="form-label"
className={cn(error && "text-destructive", className)} data-error={!!error}
className={cn("data-[error=true]:text-destructive", className)}
htmlFor={formItemId} htmlFor={formItemId}
{...props} {...props}
/> />
); );
}); }
FormLabel.displayName = "FormLabel";
const FormControl = React.forwardRef< function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
React.ElementRef<typeof Slot>,
React.ComponentPropsWithoutRef<typeof Slot>
>(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } = const { error, formItemId, formDescriptionId, formMessageId } =
useFormField(); useFormField();
return ( return (
<Slot <Slot
ref={ref} data-slot="form-control"
id={formItemId} id={formItemId}
aria-describedby={ aria-describedby={
!error !error
@ -123,32 +121,24 @@ const FormControl = React.forwardRef<
{...props} {...props}
/> />
); );
}); }
FormControl.displayName = "FormControl";
const FormDescription = React.forwardRef< function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField(); const { formDescriptionId } = useFormField();
return ( return (
<p <p
ref={ref} data-slot="form-description"
id={formDescriptionId} id={formDescriptionId}
className={cn("text-sm text-muted-foreground", className)} className={cn("text-muted-foreground text-sm", className)}
{...props} {...props}
/> />
); );
}); }
FormDescription.displayName = "FormDescription";
const FormMessage = React.forwardRef< function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField(); const { error, formMessageId } = useFormField();
const body = error ? String(error?.message) : children; const body = error ? String(error?.message ?? "") : props.children;
if (!body) { if (!body) {
return null; return null;
@ -156,16 +146,15 @@ const FormMessage = React.forwardRef<
return ( return (
<p <p
ref={ref} data-slot="form-message"
id={formMessageId} id={formMessageId}
className={cn("text-sm font-medium text-destructive", className)} className={cn("text-destructive text-sm", className)}
{...props} {...props}
> >
{body} {body}
</p> </p>
); );
}); }
FormMessage.displayName = "FormMessage";
export { export {
useFormField, useFormField,

View file

@ -2,70 +2,80 @@
import * as React from "react"; import * as React from "react";
import { OTPInput, OTPInputContext } from "input-otp"; import { OTPInput, OTPInputContext } from "input-otp";
import { Dot } from "lucide-react"; import { MinusIcon } from "lucide-react";
import { cn } from "@app/lib/cn"; import { cn } from "@app/lib/cn";
const InputOTP = React.forwardRef< function InputOTP({
React.ElementRef<typeof OTPInput>, className,
React.ComponentPropsWithoutRef<typeof OTPInput> & { obscured?: boolean } containerClassName,
>(({ className, containerClassName, obscured = false, ...props }, ref) => ( obscured = false,
<OTPInput ...props
ref={ref} }: React.ComponentProps<typeof OTPInput> & {
containerClassName={cn( containerClassName?: string;
"flex items-center gap-2 has-[:disabled]:opacity-50", obscured?: boolean;
containerClassName }) {
)} return (
className={cn("disabled:cursor-not-allowed", className)} <OTPInput
{...props} data-slot="input-otp"
/> containerClassName={cn(
)); "flex items-center gap-2 has-disabled:opacity-50",
InputOTP.displayName = "InputOTP"; containerClassName
)}
className={cn("disabled:cursor-not-allowed", className)}
{...props}
/>
);
}
const InputOTPGroup = React.forwardRef< function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
React.ElementRef<"div">, return (
React.ComponentPropsWithoutRef<"div"> <div
>(({ className, ...props }, ref) => ( data-slot="input-otp-group"
<div ref={ref} className={cn("flex items-center", className)} {...props} /> className={cn("flex items-center", className)}
)); {...props}
InputOTPGroup.displayName = "InputOTPGroup"; />
);
}
const InputOTPSlot = React.forwardRef< function InputOTPSlot({
React.ElementRef<"div">, index,
React.ComponentPropsWithoutRef<"div"> & { index: number; obscured?: boolean } className,
>(({ index, className, obscured = false, ...props }, ref) => { obscured = false,
const inputOTPContext = React.useContext(OTPInputContext); ...props
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index]; }: React.ComponentProps<"div"> & {
index: number;
obscured?: boolean;
}) {
const inputOTPContext = React.useContext(OTPInputContext);
const { char, hasFakeCaret, isActive } =
inputOTPContext?.slots[index] ?? {};
return ( return (
<div <div
ref={ref} data-slot="input-otp-slot"
className={cn( data-active={isActive}
"relative flex h-10 w-10 items-center justify-center border-y border-r border-input text-base md:text-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md", className={cn(
isActive && "z-10 ring-2 ring-ring ring-offset-background", "data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm shadow-xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]",
className className
)} )}
{...props} {...props}
> >
{char && obscured ? "•" : char} {char && obscured ? "•" : char}
{hasFakeCaret && ( {hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center"> <div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="h-4 w-px animate-caret-blink bg-foreground duration-1000" /> <div className="animate-caret-blink bg-foreground h-4 w-px duration-1000" />
</div>
)}
</div> </div>
)} );
</div> }
);
});
InputOTPSlot.displayName = "InputOTPSlot";
const InputOTPSeparator = React.forwardRef< function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
React.ElementRef<"div">, return (
React.ComponentPropsWithoutRef<"div"> <div data-slot="input-otp-separator" role="separator" {...props}>
>(({ ...props }, ref) => ( <MinusIcon />
<div ref={ref} role="separator" {...props}> </div>
<Dot /> );
</div> }
));
InputOTPSeparator.displayName = "InputOTPSeparator";
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }; export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };

View file

@ -14,8 +14,11 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
<div className="relative"> <div className="relative">
<input <input
type={showPassword ? "text" : "password"} type={showPassword ? "text" : "password"}
data-slot="input"
className={cn( className={cn(
"flex h-9 w-full rounded-md border border-input bg-card px-3 py-2 text-base md:text-sm ring-offset-background file:border-0 file:bg-transparent file:text-base md:file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50", "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base inset-shadow-2xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className className
)} )}
ref={ref} ref={ref}
@ -38,8 +41,11 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
) : ( ) : (
<input <input
type={type} type={type}
data-slot="input"
className={cn( className={cn(
"flex h-9 w-full rounded-md border border-input bg-card px-3 py-2 text-base md:text-sm ring-offset-background file:border-0 file:bg-transparent file:text-base md:file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50", "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base inset-shadow-2xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className className
)} )}
ref={ref} ref={ref}

View file

@ -2,25 +2,22 @@
import * as React from "react"; import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label"; import * as LabelPrimitive from "@radix-ui/react-label";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@app/lib/cn"; import { cn } from "@app/lib/cn";
const labelVariants = cva( function Label({
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" className,
); ...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
const Label = React.forwardRef< return (
React.ElementRef<typeof LabelPrimitive.Root>, <LabelPrimitive.Root
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & data-slot="label"
VariantProps<typeof labelVariants> className={cn(
>(({ className, ...props }, ref) => ( "flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
<LabelPrimitive.Root className
ref={ref} )}
className={cn(labelVariants(), className)} {...props}
{...props} />
/> );
)); }
Label.displayName = LabelPrimitive.Root.displayName;
export { Label }; export { Label };

View file

@ -2,39 +2,46 @@
import * as React from "react"; import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover"; import * as PopoverPrimitive from "@radix-ui/react-popover";
import { cn } from "@app/lib/cn"; import { cn } from "@app/lib/cn";
const Popover = PopoverPrimitive.Root; function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
}
const PopoverTrigger = React.forwardRef< function PopoverTrigger({
React.ElementRef<typeof PopoverPrimitive.Trigger>, ...props
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Trigger> }: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
>(({ className, ...props }, ref) => ( return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
<PopoverPrimitive.Trigger }
ref={ref}
className={cn(className, "rounded-md")}
{...props}
/>
));
const PopoverContent = React.forwardRef< function PopoverContent({
React.ElementRef<typeof PopoverPrimitive.Content>, className,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content> align = "center",
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( sideOffset = 4,
<PopoverPrimitive.Portal> ...props
<PopoverPrimitive.Content }: React.ComponentProps<typeof PopoverPrimitive.Content>) {
ref={ref} return (
align={align} <PopoverPrimitive.Portal>
sideOffset={sideOffset} <PopoverPrimitive.Content
className={cn( data-slot="popover-content"
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", align={align}
className sideOffset={sideOffset}
)} className={cn(
{...props} "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
/> className
</PopoverPrimitive.Portal> )}
)); {...props}
PopoverContent.displayName = PopoverPrimitive.Content.displayName; />
</PopoverPrimitive.Portal>
);
}
export { Popover, PopoverTrigger, PopoverContent }; function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };

View file

@ -2,160 +2,189 @@
import * as React from "react"; import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select"; import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown, ChevronUp } from "lucide-react"; import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { cn } from "@app/lib/cn"; import { cn } from "@app/lib/cn";
const Select = SelectPrimitive.Root; function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />;
}
const SelectGroup = SelectPrimitive.Group; function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
}
const SelectValue = SelectPrimitive.Value; function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
}
const SelectTrigger = React.forwardRef< function SelectTrigger({
React.ElementRef<typeof SelectPrimitive.Trigger>, className,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> size = "default",
>(({ className, children, ...props }, ref) => ( children,
<SelectPrimitive.Trigger ...props
ref={ref} }: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
className={cn( size?: "sm" | "default";
"flex h-9 w-full items-center justify-between border border-input bg-card px-3 py-2 text-base md:text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1", }) {
"rounded-md", return (
className <SelectPrimitive.Trigger
)} data-slot="select-trigger"
{...props} data-size={size}
> className={cn(
{children} "border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive cursor-pointer flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 w-full",
<SelectPrimitive.Icon asChild> className
<ChevronDown className="h-4 w-4 opacity-50" /> )}
</SelectPrimitive.Icon> {...props}
</SelectPrimitive.Trigger> >
)); {children}
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName; <SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
}
const SelectScrollUpButton = React.forwardRef< function SelectContent({
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>, className,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton> children,
>(({ className, ...props }, ref) => ( position = "popper",
<SelectPrimitive.ScrollUpButton ...props
ref={ref} }: React.ComponentProps<typeof SelectPrimitive.Content>) {
className={cn( return (
"flex cursor-default items-center justify-center py-1", <SelectPrimitive.Portal>
className <SelectPrimitive.Content
)} data-slot="select-content"
{...props} className={cn(
> "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
<ChevronUp className="h-4 w-4" /> position === "popper" &&
</SelectPrimitive.ScrollUpButton> "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
)); className
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName; )}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
);
}
const SelectScrollDownButton = React.forwardRef< function SelectLabel({
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>, className,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton> ...props
>(({ className, ...props }, ref) => ( }: React.ComponentProps<typeof SelectPrimitive.Label>) {
<SelectPrimitive.ScrollDownButton return (
ref={ref} <SelectPrimitive.Label
className={cn( data-slot="select-label"
"flex cursor-default items-center justify-center py-1", className={cn(
className "text-muted-foreground px-2 py-1.5 text-xs",
)} className
{...props} )}
> {...props}
<ChevronDown className="h-4 w-4" /> />
</SelectPrimitive.ScrollDownButton> );
)); }
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = React.forwardRef< function SelectItem({
React.ElementRef<typeof SelectPrimitive.Content>, className,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content> children,
>(({ className, children, position = "popper", ...props }, ref) => ( ...props
<SelectPrimitive.Portal> }: React.ComponentProps<typeof SelectPrimitive.Item>) {
<SelectPrimitive.Content return (
ref={ref} <SelectPrimitive.Item
className={cn( data-slot="select-item"
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", className={cn(
position === "popper" && "focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", className
className )}
)} {...props}
position={position} >
{...props} <span className="absolute right-2 flex size-3.5 items-center justify-center">
> <SelectPrimitive.ItemIndicator>
<SelectScrollUpButton /> <CheckIcon className="size-4" />
<SelectPrimitive.Viewport </SelectPrimitive.ItemIndicator>
className={cn( </span>
"p-1", <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
position === "popper" && </SelectPrimitive.Item>
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]" );
)} }
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef< function SelectSeparator({
React.ElementRef<typeof SelectPrimitive.Label>, className,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label> ...props
>(({ className, ...props }, ref) => ( }: React.ComponentProps<typeof SelectPrimitive.Separator>) {
<SelectPrimitive.Label return (
ref={ref} <SelectPrimitive.Separator
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)} data-slot="select-separator"
{...props} className={cn(
/> "bg-border pointer-events-none -mx-1 my-1 h-px",
)); className
SelectLabel.displayName = SelectPrimitive.Label.displayName; )}
{...props}
/>
);
}
const SelectItem = React.forwardRef< function SelectScrollUpButton({
React.ElementRef<typeof SelectPrimitive.Item>, className,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item> ...props
>(({ className, children, ...props }, ref) => ( }: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
<SelectPrimitive.Item return (
ref={ref} <SelectPrimitive.ScrollUpButton
className={cn( data-slot="select-scroll-up-button"
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", className={cn(
className "flex cursor-default items-center justify-center py-1",
)} className
{...props} )}
> {...props}
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> >
<SelectPrimitive.ItemIndicator> <ChevronUpIcon className="size-4" />
<Check className="h-4 w-4" /> </SelectPrimitive.ScrollUpButton>
</SelectPrimitive.ItemIndicator> );
</span> }
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText> function SelectScrollDownButton({
</SelectPrimitive.Item> className,
)); ...props
SelectItem.displayName = SelectPrimitive.Item.displayName; }: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
const SelectSeparator = React.forwardRef< <SelectPrimitive.ScrollDownButton
React.ElementRef<typeof SelectPrimitive.Separator>, data-slot="select-scroll-down-button"
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator> className={cn(
>(({ className, ...props }, ref) => ( "flex cursor-default items-center justify-center py-1",
<SelectPrimitive.Separator className
ref={ref} )}
className={cn("-mx-1 my-1 h-px bg-muted", className)} {...props}
{...props} >
/> <ChevronDownIcon className="size-4" />
)); </SelectPrimitive.ScrollDownButton>
SelectSeparator.displayName = SelectPrimitive.Separator.displayName; );
}
export { export {
Select, Select,
SelectGroup, SelectContent,
SelectValue, SelectGroup,
SelectTrigger, SelectItem,
SelectContent, SelectLabel,
SelectLabel, SelectScrollDownButton,
SelectItem, SelectScrollUpButton,
SelectSeparator, SelectSeparator,
SelectScrollUpButton, SelectTrigger,
SelectScrollDownButton, SelectValue
}; };

View file

@ -1,29 +1,30 @@
"use client"; "use client";
import * as React from "react"; import * as React from "react";
import * as SwitchPrimitives from "@radix-ui/react-switch"; import * as SwitchPrimitive from "@radix-ui/react-switch";
import { cn } from "@app/lib/cn"; import { cn } from "@app/lib/cn";
const Switch = React.forwardRef< function Switch({
React.ElementRef<typeof SwitchPrimitives.Root>, className,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root> ...props
>(({ className, ...props }, ref) => ( }: React.ComponentProps<typeof SwitchPrimitive.Root>) {
<SwitchPrimitives.Root return (
className={cn( <SwitchPrimitive.Root
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-0 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input", data-slot="switch"
className className={cn(
)} "cursor-pointer peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
{...props} className
ref={ref} )}
> {...props}
<SwitchPrimitives.Thumb >
className={cn( <SwitchPrimitive.Thumb
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0.5" data-slot="switch-thumb"
)} className={cn(
/> "cursor-pointer bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-3.5 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(112%-2px)] data-[state=unchecked]:translate-x-[calc(18%)]"
</SwitchPrimitives.Root> )}
)); />
Switch.displayName = SwitchPrimitives.Root.displayName; </SwitchPrimitive.Root>
);
}
export { Switch }; export { Switch };

View file

@ -10,120 +10,120 @@ import { cn } from "@app/lib/cn";
const ToastProvider = ToastPrimitives.Provider; const ToastProvider = ToastPrimitives.Provider;
const ToastViewport = React.forwardRef< const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>, React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport> React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport <ToastPrimitives.Viewport
ref={ref} ref={ref}
className={cn( className={cn(
"fixed top-0 right-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 md:max-w-[420px]", "fixed bottom-0 left-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 md:max-w-[420px]",
className className
)} )}
{...props} {...props}
/> />
)); ));
ToastViewport.displayName = ToastPrimitives.Viewport.displayName; ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
const toastVariants = cva( const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-3 pr-8 transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=open]:fade-in data-[state=closed]:animate-out data-[state=closed]:fade-out data-[swipe=end]:animate-out", "shadow-sm group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-3 pr-8 transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=open]:fade-in data-[state=closed]:animate-out data-[state=closed]:fade-out data-[swipe=end]:animate-out",
{ {
variants: { variants: {
variant: { variant: {
default: "border bg-card text-foreground", default: "border bg-card text-foreground",
destructive: destructive:
"destructive group border-destructive bg-destructive text-destructive-foreground", "destructive group border-destructive bg-destructive text-destructive-foreground"
}, }
}, },
defaultVariants: { defaultVariants: {
variant: "default", variant: "default"
}, }
} }
); );
const Toast = React.forwardRef< const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>, React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> & React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants> VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => { >(({ className, variant, ...props }, ref) => {
return ( return (
<ToastPrimitives.Root <ToastPrimitives.Root
ref={ref} ref={ref}
className={cn(toastVariants({ variant }), className)} className={cn(toastVariants({ variant }), className)}
{...props} {...props}
/> />
); );
}); });
Toast.displayName = ToastPrimitives.Root.displayName; Toast.displayName = ToastPrimitives.Root.displayName;
const ToastAction = React.forwardRef< const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>, React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action> React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<ToastPrimitives.Action <ToastPrimitives.Action
ref={ref} ref={ref}
className={cn( className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-0 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive", "inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-0 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
className className
)} )}
{...props} {...props}
/> />
)); ));
ToastAction.displayName = ToastPrimitives.Action.displayName; ToastAction.displayName = ToastPrimitives.Action.displayName;
const ToastClose = React.forwardRef< const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>, React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close> React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<ToastPrimitives.Close <ToastPrimitives.Close
ref={ref} ref={ref}
className={cn( className={cn(
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600", "absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
className className
)} )}
toast-close="" toast-close=""
{...props} {...props}
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</ToastPrimitives.Close> </ToastPrimitives.Close>
)); ));
ToastClose.displayName = ToastPrimitives.Close.displayName; ToastClose.displayName = ToastPrimitives.Close.displayName;
const ToastTitle = React.forwardRef< const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>, React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title> React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<ToastPrimitives.Title <ToastPrimitives.Title
ref={ref} ref={ref}
className={cn("text-sm font-semibold", className)} className={cn("text-sm font-semibold", className)}
{...props} {...props}
/> />
)); ));
ToastTitle.displayName = ToastPrimitives.Title.displayName; ToastTitle.displayName = ToastPrimitives.Title.displayName;
const ToastDescription = React.forwardRef< const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>, React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description> React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<ToastPrimitives.Description <ToastPrimitives.Description
ref={ref} ref={ref}
className={cn("text-sm opacity-90", className)} className={cn("text-sm opacity-90", className)}
{...props} {...props}
/> />
)); ));
ToastDescription.displayName = ToastPrimitives.Description.displayName; ToastDescription.displayName = ToastPrimitives.Description.displayName;
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast> type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>;
type ToastActionElement = React.ReactElement<typeof ToastAction> type ToastActionElement = React.ReactElement<typeof ToastAction>;
export { export {
type ToastProps, type ToastProps,
type ToastActionElement, type ToastActionElement,
ToastProvider, ToastProvider,
ToastViewport, ToastViewport,
Toast, Toast,
ToastTitle, ToastTitle,
ToastDescription, ToastDescription,
ToastClose, ToastClose,
ToastAction, ToastAction
}; };

View file

@ -2,34 +2,42 @@
import { useToast } from "@/hooks/useToast"; import { useToast } from "@/hooks/useToast";
import { import {
Toast, Toast,
ToastClose, ToastClose,
ToastDescription, ToastDescription,
ToastProvider, ToastProvider,
ToastTitle, ToastTitle,
ToastViewport, ToastViewport
} from "@/components/ui/toast"; } from "@/components/ui/toast";
export function Toaster() { export function Toaster() {
const { toasts } = useToast(); const { toasts } = useToast();
return ( return (
<ToastProvider> <ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) { {toasts.map(function ({
return ( id,
<Toast key={id} {...props} className="mt-2"> title,
<div className="grid gap-1"> description,
{title && <ToastTitle>{title}</ToastTitle>} action,
{description && ( ...props
<ToastDescription>{description}</ToastDescription> }) {
)} return (
</div> <Toast key={id} {...props} className="mt-2">
{action} <div className="grid gap-1">
<ToastClose /> {title && <ToastTitle>{title}</ToastTitle>}
</Toast> {description && (
); <ToastDescription>
})} {description}
<ToastViewport /> </ToastDescription>
</ToastProvider> )}
); </div>
{action}
<ToastClose />
</Toast>
);
})}
<ToastViewport />
</ToastProvider>
);
} }

View file

@ -0,0 +1,60 @@
"use client";
import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { cn } from "@app/lib/cn";
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
);
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
);
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
);
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };