Merge branch 'dev' into clients-pops

This commit is contained in:
Owen
2025-06-11 11:13:40 -04:00
149 changed files with 13888 additions and 5083 deletions

View File

@@ -11,6 +11,7 @@ import {
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Users, Globe, Database, Cog, Settings, Waypoints, Combine } from "lucide-react";
import { useTranslations } from "next-intl";
interface OrgStat {
label: string;
@@ -38,19 +39,21 @@ export default function OrganizationLandingCard(
) {
const [orgData] = useState(props);
const t = useTranslations();
const orgStats: OrgStat[] = [
{
label: "Sites",
label: t('sites'),
value: orgData.overview.stats.sites,
icon: <Combine className="h-6 w-6" />
},
{
label: "Resources",
label: t('resources'),
value: orgData.overview.stats.resources,
icon: <Waypoints className="h-6 w-6" />
},
{
label: "Users",
label: t('users'),
value: orgData.overview.stats.users,
icon: <Users className="h-6 w-6" />
}
@@ -81,9 +84,9 @@ export default function OrganizationLandingCard(
))}
</div>
<div className="text-center text-lg">
Your role:{" "}
{t('accessRoleYour')}{" "}
<span className="font-semibold">
{orgData.overview.isOwner ? "Owner" : orgData.overview.userRole}
{orgData.overview.isOwner ? t('accessRoleOwner') : orgData.overview.userRole}
</span>
</div>
</CardContent>
@@ -92,7 +95,7 @@ export default function OrganizationLandingCard(
<Link href={`/${orgData.overview.orgId}/settings`}>
<Button size="lg" className="w-full md:w-auto">
<Settings className="mr-2 h-4 w-4" />
Organization Settings
{t('orgGeneralSettings')}
</Button>
</Link>
</CardFooter>

View File

@@ -2,6 +2,7 @@
import { HorizontalTabs } from "@app/components/HorizontalTabs";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { useTranslations } from "next-intl";
interface AccessPageHeaderAndNavProps {
children: React.ReactNode;
@@ -12,20 +13,22 @@ export default function AccessPageHeaderAndNav({
children,
hasInvitations
}: AccessPageHeaderAndNavProps) {
const t = useTranslations();
const navItems = [
{
title: "Users",
title: t('users'),
href: `/{orgId}/settings/access/users`
},
{
title: "Roles",
title: t('roles'),
href: `/{orgId}/settings/access/roles`
}
];
if (hasInvitations) {
navItems.push({
title: "Invitations",
title: t('invite'),
href: `/{orgId}/settings/access/invitations`
});
}
@@ -33,8 +36,8 @@ export default function AccessPageHeaderAndNav({
return (
<>
<SettingsSectionTitle
title="Manage Users & Roles"
description="Invite users and add them to roles to manage access to your organization"
title={t('accessUsersRoles')}
description={t('accessUsersRolesDescription')}
/>
<HorizontalTabs items={navItems}>

View File

@@ -4,6 +4,7 @@ import {
ColumnDef,
} from "@tanstack/react-table";
import { DataTable } from "@app/components/ui/data-table";
import { useTranslations } from 'next-intl';
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
@@ -14,12 +15,15 @@ export function InvitationsDataTable<TData, TValue>({
columns,
data
}: DataTableProps<TData, TValue>) {
const t = useTranslations();
return (
<DataTable
columns={columns}
data={data}
title="Invitations"
searchPlaceholder="Search invitations..."
title={t('invite')}
searchPlaceholder={t('inviteSearch')}
searchColumn="email"
/>
);

View File

@@ -17,6 +17,7 @@ import { useOrgContext } from "@app/hooks/useOrgContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useTranslations } from "next-intl";
export type InvitationRow = {
id: string;
@@ -39,6 +40,8 @@ export default function InvitationsTable({
const [selectedInvitation, setSelectedInvitation] =
useState<InvitationRow | null>(null);
const t = useTranslations();
const api = createApiClient(useEnvContext());
const { org } = useOrgContext();
@@ -51,7 +54,7 @@ export default function InvitationsTable({
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<span className="sr-only">{t('openMenu')}</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
@@ -62,7 +65,7 @@ export default function InvitationsTable({
setSelectedInvitation(invitation);
}}
>
<span>Regenerate Invitation</span>
<span>{t('inviteRegenerate')}</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
@@ -71,7 +74,7 @@ export default function InvitationsTable({
}}
>
<span className="text-red-500">
Remove Invitation
{t('inviteRemove')}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
@@ -81,11 +84,11 @@ export default function InvitationsTable({
},
{
accessorKey: "email",
header: "Email"
header: t('email')
},
{
accessorKey: "expiresAt",
header: "Expires At",
header: t('expiresAt'),
cell: ({ row }) => {
const expiresAt = new Date(row.original.expiresAt);
const isExpired = expiresAt < new Date();
@@ -99,7 +102,7 @@ export default function InvitationsTable({
},
{
accessorKey: "role",
header: "Role"
header: t('role')
}
];
@@ -112,17 +115,16 @@ export default function InvitationsTable({
.catch((e) => {
toast({
variant: "destructive",
title: "Failed to remove invitation",
description:
"An error occurred while removing the invitation."
title: t('inviteRemoveError'),
description: t('inviteRemoveErrorDescription')
});
});
if (res && res.status === 200) {
toast({
variant: "default",
title: "Invitation removed",
description: `The invitation for ${selectedInvitation.email} has been removed.`
title: t('inviteRemoved'),
description: t('inviteRemovedDescription', {email: selectedInvitation.email})
});
setInvitations((prev) =>
@@ -146,23 +148,20 @@ export default function InvitationsTable({
dialog={
<div className="space-y-4">
<p>
Are you sure you want to remove the invitation for{" "}
<b>{selectedInvitation?.email}</b>?
{t('inviteQuestionRemove', {email: selectedInvitation?.email || ""})}
</p>
<p>
Once removed, this invitation will no longer be
valid. You can always re-invite the user later.
{t('inviteMessageRemove')}
</p>
<p>
To confirm, please type the email address of the
invitation below.
{t('inviteMessageConfirm')}
</p>
</div>
}
buttonText="Confirm Remove Invitation"
buttonText={t('inviteRemoveConfirm')}
onConfirm={removeInvitation}
string={selectedInvitation?.email ?? ""}
title="Remove Invitation"
title={t('inviteRemove')}
/>
<RegenerateInvitationForm
open={isRegenerateModalOpen}

View File

@@ -24,6 +24,7 @@ import {
SelectValue
} from "@app/components/ui/select";
import { Label } from "@app/components/ui/label";
import { useTranslations } from "next-intl";
type RegenerateInvitationFormProps = {
open: boolean;
@@ -56,14 +57,16 @@ export default function RegenerateInvitationForm({
const api = createApiClient(useEnvContext());
const { org } = useOrgContext();
const t = useTranslations();
const validForOptions = [
{ hours: 24, name: "1 day" },
{ hours: 48, name: "2 days" },
{ hours: 72, name: "3 days" },
{ hours: 96, name: "4 days" },
{ hours: 120, name: "5 days" },
{ hours: 144, name: "6 days" },
{ hours: 168, name: "7 days" }
{ hours: 24, name: t('day', {count: 1}) },
{ hours: 48, name: t('day', {count: 2}) },
{ hours: 72, name: t('day', {count: 3}) },
{ hours: 96, name: t('day', {count: 4}) },
{ hours: 120, name: t('day', {count: 5}) },
{ hours: 144, name: t('day', {count: 6}) },
{ hours: 168, name: t('day', {count: 7}) }
];
useEffect(() => {
@@ -79,9 +82,8 @@ export default function RegenerateInvitationForm({
if (!org?.org.orgId) {
toast({
variant: "destructive",
title: "Organization ID Missing",
description:
"Unable to regenerate invitation without an organization ID.",
title: t('orgMissing'),
description: t('orgMissingMessage'),
duration: 5000
});
return;
@@ -105,15 +107,15 @@ export default function RegenerateInvitationForm({
if (sendEmail) {
toast({
variant: "default",
title: "Invitation Regenerated",
description: `A new invitation has been sent to ${invitation.email}.`,
title: t('inviteRegenerated'),
description: t('inviteSent', {email: invitation.email}),
duration: 5000
});
} else {
toast({
variant: "default",
title: "Invitation Regenerated",
description: `A new invitation has been generated for ${invitation.email}.`,
title: t('inviteRegenerated'),
description: t('inviteGenerate', {email: invitation.email}),
duration: 5000
});
}
@@ -130,24 +132,22 @@ export default function RegenerateInvitationForm({
if (error.response?.status === 409) {
toast({
variant: "destructive",
title: "Duplicate Invite",
description: "An invitation for this user already exists.",
title: t('inviteDuplicateError'),
description: t('inviteDuplicateErrorDescription'),
duration: 5000
});
} else if (error.response?.status === 429) {
toast({
variant: "destructive",
title: "Rate Limit Exceeded",
description:
"You have exceeded the limit of 3 regenerations per hour. Please try again later.",
title: t('inviteRateLimitError'),
description: t('inviteRateLimitErrorDescription'),
duration: 5000
});
} else {
toast({
variant: "destructive",
title: "Failed to Regenerate Invitation",
description:
"An error occurred while regenerating the invitation.",
title: t('inviteRegenerateError'),
description: t('inviteRegenerateErrorDescription'),
duration: 5000
});
}
@@ -168,18 +168,16 @@ export default function RegenerateInvitationForm({
>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>Regenerate Invitation</CredenzaTitle>
<CredenzaTitle>{t('inviteRegenerate')}</CredenzaTitle>
<CredenzaDescription>
Revoke previous invitation and create a new one
{t('inviteRegenerateDescription')}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
{!inviteLink ? (
<div>
<p>
Are you sure you want to regenerate the
invitation for <b>{invitation?.email}</b>? This
will revoke the previous invitation.
{t('inviteQuestionRegenerate', {email: invitation?.email || ""})}
</p>
<div className="flex items-center space-x-2 mt-4">
<Checkbox
@@ -190,12 +188,12 @@ export default function RegenerateInvitationForm({
}
/>
<label htmlFor="send-email">
Send email notification to the user
{t('inviteSentEmail')}
</label>
</div>
<div className="mt-4 space-y-2">
<Label>
Validity Period
{t('inviteValidityPeriod')}
</Label>
<Select
value={validHours.toString()}
@@ -204,7 +202,7 @@ export default function RegenerateInvitationForm({
}
>
<SelectTrigger>
<SelectValue placeholder="Select validity period" />
<SelectValue placeholder={t('inviteValidityPeriodSelect')} />
</SelectTrigger>
<SelectContent>
{validForOptions.map((option) => (
@@ -222,9 +220,7 @@ export default function RegenerateInvitationForm({
) : (
<div className="space-y-4 max-w-md">
<p>
The invitation has been regenerated. The user
must access the link below to accept the
invitation.
{t('inviteRegenerateMessage')}
</p>
<CopyTextBox text={inviteLink} wrapText={false} />
</div>
@@ -234,18 +230,18 @@ export default function RegenerateInvitationForm({
{!inviteLink ? (
<>
<CredenzaClose asChild>
<Button variant="outline">Cancel</Button>
<Button variant="outline">{t('cancel')}</Button>
</CredenzaClose>
<Button
onClick={handleRegenerate}
loading={loading}
>
Regenerate
{t('inviteRegenerateButton')}
</Button>
</>
) : (
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
<Button variant="outline">{t('close')}</Button>
</CredenzaClose>
)}
</CredenzaFooter>

View File

@@ -9,6 +9,7 @@ import UserProvider from "@app/providers/UserProvider";
import { verifySession } from "@app/lib/auth/verifySession";
import AccessPageHeaderAndNav from "../AccessPageHeaderAndNav";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { getTranslations } from 'next-intl/server';
type InvitationsPageProps = {
params: Promise<{ orgId: string }>;
@@ -18,6 +19,7 @@ export const dynamic = "force-dynamic";
export default async function InvitationsPage(props: InvitationsPageProps) {
const params = await props.params;
const t = await getTranslations();
const getUser = cache(verifySession);
const user = await getUser();
@@ -66,7 +68,7 @@ export default async function InvitationsPage(props: InvitationsPageProps) {
id: invite.inviteId,
email: invite.email,
expiresAt: new Date(Number(invite.expiresAt)).toISOString(),
role: invite.roleName || "Unknown Role",
role: invite.roleName || t('accessRoleUnknown'),
roleId: invite.roleId
};
});
@@ -74,8 +76,8 @@ export default async function InvitationsPage(props: InvitationsPageProps) {
return (
<>
<SettingsSectionTitle
title="Open Invitations"
description="Manage your invitations to other users"
title={t('inviteTitle')}
description={t('inviteDescription')}
/>
<UserProvider user={user!}>
<OrgProvider org={org}>

View File

@@ -31,6 +31,7 @@ import { CreateRoleBody, CreateRoleResponse } from "@server/routers/role";
import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useTranslations } from "next-intl";
type CreateRoleFormProps = {
open: boolean;
@@ -38,17 +39,18 @@ type CreateRoleFormProps = {
afterCreate?: (res: CreateRoleResponse) => Promise<void>;
};
const formSchema = z.object({
name: z.string({ message: "Name is required" }).max(32),
description: z.string().max(255).optional()
});
export default function CreateRoleForm({
open,
setOpen,
afterCreate
}: CreateRoleFormProps) {
const { org } = useOrgContext();
const t = useTranslations();
const formSchema = z.object({
name: z.string({ message: t('nameRequired') }).max(32),
description: z.string().max(255).optional()
});
const [loading, setLoading] = useState(false);
@@ -76,10 +78,10 @@ export default function CreateRoleForm({
.catch((e) => {
toast({
variant: "destructive",
title: "Failed to create role",
title: t('accessRoleErrorCreate'),
description: formatAxiosError(
e,
"An error occurred while creating the role."
t('accessRoleErrorCreateDescription')
)
});
});
@@ -87,8 +89,8 @@ export default function CreateRoleForm({
if (res && res.status === 201) {
toast({
variant: "default",
title: "Role created",
description: "The role has been successfully created."
title: t('accessRoleCreated'),
description: t('accessRoleCreatedDescription')
});
if (open) {
@@ -115,10 +117,9 @@ export default function CreateRoleForm({
>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>Create Role</CredenzaTitle>
<CredenzaTitle>{t('accessRoleCreate')}</CredenzaTitle>
<CredenzaDescription>
Create a new role to group users and manage their
permissions.
{t('accessRoleCreateDescription')}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
@@ -133,7 +134,7 @@ export default function CreateRoleForm({
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Role Name</FormLabel>
<FormLabel>{t('accessRoleName')}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
@@ -146,7 +147,7 @@ export default function CreateRoleForm({
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormLabel>{t('description')}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
@@ -159,7 +160,7 @@ export default function CreateRoleForm({
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
<Button variant="outline">{t('close')}</Button>
</CredenzaClose>
<Button
type="submit"
@@ -167,7 +168,7 @@ export default function CreateRoleForm({
loading={loading}
disabled={loading}
>
Create Role
{t('accessRoleCreateSubmit')}
</Button>
</CredenzaFooter>
</CredenzaContent>

View File

@@ -38,6 +38,7 @@ import { RoleRow } from "./RolesTable";
import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useTranslations } from "next-intl";
type CreateRoleFormProps = {
open: boolean;
@@ -46,10 +47,6 @@ type CreateRoleFormProps = {
afterDelete?: () => void;
};
const formSchema = z.object({
newRoleId: z.string({ message: "New role is required" })
});
export default function DeleteRoleForm({
open,
roleToDelete,
@@ -57,12 +54,17 @@ export default function DeleteRoleForm({
afterDelete
}: CreateRoleFormProps) {
const { org } = useOrgContext();
const t = useTranslations();
const [loading, setLoading] = useState(false);
const [roles, setRoles] = useState<ListRolesResponse["roles"]>([]);
const api = createApiClient(useEnvContext());
const formSchema = z.object({
newRoleId: z.string({ message: t('accessRoleErrorNewRequired') })
});
useEffect(() => {
async function fetchRoles() {
const res = await api
@@ -73,10 +75,10 @@ export default function DeleteRoleForm({
console.error(e);
toast({
variant: "destructive",
title: "Failed to fetch roles",
title: t('accessRoleErrorFetch'),
description: formatAxiosError(
e,
"An error occurred while fetching the roles"
t('accessRoleErrorFetchDescription')
)
});
});
@@ -112,10 +114,10 @@ export default function DeleteRoleForm({
.catch((e) => {
toast({
variant: "destructive",
title: "Failed to remove role",
title: t('accessRoleErrorRemove'),
description: formatAxiosError(
e,
"An error occurred while removing the role."
t('accessRoleErrorRemoveDescription')
)
});
});
@@ -123,8 +125,8 @@ export default function DeleteRoleForm({
if (res && res.status === 200) {
toast({
variant: "default",
title: "Role removed",
description: "The role has been successfully removed."
title: t('accessRoleRemoved'),
description: t('accessRoleRemovedDescription')
});
if (open) {
@@ -151,22 +153,19 @@ export default function DeleteRoleForm({
>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>Remove Role</CredenzaTitle>
<CredenzaTitle>{t('accessRoleRemove')}</CredenzaTitle>
<CredenzaDescription>
Remove a role from the organization
{t('accessRoleRemoveDescription')}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<div className="space-y-4">
<div className="space-y-4">
<p>
You're about to delete the{" "}
<b>{roleToDelete.name}</b> role. You cannot
undo this action.
{t('accessRoleQuestionRemove', {name: roleToDelete.name})}
</p>
<p>
Before deleting this role, please select a
new role to transfer existing members to.
{t('accessRoleRequiredRemove')}
</p>
</div>
<Form {...form}>
@@ -180,7 +179,7 @@ export default function DeleteRoleForm({
name="newRoleId"
render={({ field }) => (
<FormItem>
<FormLabel>Role</FormLabel>
<FormLabel>{t('role')}</FormLabel>
<Select
onValueChange={
field.onChange
@@ -189,7 +188,7 @@ export default function DeleteRoleForm({
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select role" />
<SelectValue placeholder={t('accessRoleSelect')} />
</SelectTrigger>
</FormControl>
<SelectContent>
@@ -215,7 +214,7 @@ export default function DeleteRoleForm({
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
<Button variant="outline">{t('close')}</Button>
</CredenzaClose>
<Button
type="submit"
@@ -223,7 +222,7 @@ export default function DeleteRoleForm({
loading={loading}
disabled={loading}
>
Remove Role
{t('accessRoleRemoveSubmit')}
</Button>
</CredenzaFooter>
</CredenzaContent>

View File

@@ -4,6 +4,7 @@ import {
ColumnDef,
} from "@tanstack/react-table";
import { DataTable } from "@app/components/ui/data-table";
import { useTranslations } from 'next-intl';
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
@@ -16,15 +17,18 @@ export function RolesDataTable<TData, TValue>({
data,
createRole
}: DataTableProps<TData, TValue>) {
const t = useTranslations();
return (
<DataTable
columns={columns}
data={data}
title="Roles"
searchPlaceholder="Search roles..."
title={t('roles')}
searchPlaceholder={t('accessRolesSearch')}
searchColumn="name"
onAdd={createRole}
addButtonText="Add Role"
addButtonText={t('accessRolesAdd')}
/>
);
}

View File

@@ -19,6 +19,7 @@ import CreateRoleForm from "./CreateRoleForm";
import DeleteRoleForm from "./DeleteRoleForm";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useTranslations } from 'next-intl';
export type RoleRow = Role;
@@ -38,6 +39,8 @@ export default function UsersTable({ roles: r }: RolesTableProps) {
const { org } = useOrgContext();
const t = useTranslations();
const columns: ColumnDef<RoleRow>[] = [
{
id: "actions",
@@ -58,7 +61,7 @@ export default function UsersTable({ roles: r }: RolesTableProps) {
className="h-8 w-8 p-0"
>
<span className="sr-only">
Open menu
{t('openMenu')}
</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
@@ -71,7 +74,7 @@ export default function UsersTable({ roles: r }: RolesTableProps) {
}}
>
<span className="text-red-500">
Delete Role
{t('accessRoleDelete')}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
@@ -92,7 +95,7 @@ export default function UsersTable({ roles: r }: RolesTableProps) {
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Name
{t('name')}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
@@ -100,7 +103,7 @@ export default function UsersTable({ roles: r }: RolesTableProps) {
},
{
accessorKey: "description",
header: "Description"
header: t('description')
}
];

View File

@@ -9,6 +9,7 @@ import RolesTable, { RoleRow } from "./RolesTable";
import { SidebarSettings } from "@app/components/SidebarSettings";
import AccessPageHeaderAndNav from "../AccessPageHeaderAndNav";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { getTranslations } from 'next-intl/server';
type RolesPageProps = {
params: Promise<{ orgId: string }>;
@@ -62,12 +63,13 @@ export default async function RolesPage(props: RolesPageProps) {
}
const roleRows: RoleRow[] = roles;
const t = await getTranslations();
return (
<>
<SettingsSectionTitle
title="Manage Roles"
description="Configure roles to manage access to your organization"
title={t('accessRolesManage')}
description={t('accessRolesDescription')}
/>
<OrgProvider org={org}>
<RolesTable roles={roleRows} />

View File

@@ -4,6 +4,7 @@ import {
ColumnDef,
} from "@tanstack/react-table";
import { DataTable } from "@app/components/ui/data-table";
import { useTranslations } from 'next-intl';
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
@@ -16,15 +17,18 @@ export function UsersDataTable<TData, TValue>({
data,
inviteUser
}: DataTableProps<TData, TValue>) {
const t = useTranslations();
return (
<DataTable
columns={columns}
data={data}
title="Users"
searchPlaceholder="Search users..."
title={t('users')}
searchPlaceholder={t('accessUsersSearch')}
searchColumn="email"
onAdd={inviteUser}
addButtonText="Create User"
addButtonText={t('accessUserCreate')}
/>
);
}

View File

@@ -20,6 +20,7 @@ import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useUserContext } from "@app/hooks/useUserContext";
import { useTranslations } from 'next-intl';
export type UserRow = {
id: string;
@@ -47,6 +48,7 @@ export default function UsersTable({ users: u }: UsersTableProps) {
const api = createApiClient(useEnvContext());
const { user, updateUser } = useUserContext();
const { org } = useOrgContext();
const t = useTranslations();
const columns: ColumnDef<UserRow>[] = [
{
@@ -68,7 +70,7 @@ export default function UsersTable({ users: u }: UsersTableProps) {
className="h-8 w-8 p-0"
>
<span className="sr-only">
Open menu
{t('openMenu')}
</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
@@ -79,7 +81,7 @@ export default function UsersTable({ users: u }: UsersTableProps) {
className="block w-full"
>
<DropdownMenuItem>
Manage User
{t('accessUsersManage')}
</DropdownMenuItem>
</Link>
{`${userRow.username}-${userRow.idpId}` !==
@@ -95,7 +97,7 @@ export default function UsersTable({ users: u }: UsersTableProps) {
}}
>
<span className="text-red-500">
Remove User
{t('accessUserRemove')}
</span>
</DropdownMenuItem>
)}
@@ -118,7 +120,7 @@ export default function UsersTable({ users: u }: UsersTableProps) {
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Username
{t('username')}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
@@ -134,7 +136,7 @@ export default function UsersTable({ users: u }: UsersTableProps) {
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Identity Provider
{t('identityProvider')}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
@@ -150,7 +152,7 @@ export default function UsersTable({ users: u }: UsersTableProps) {
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Role
{t('role')}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
@@ -179,7 +181,7 @@ export default function UsersTable({ users: u }: UsersTableProps) {
variant="ghost"
className="opacity-0 cursor-default"
>
Placeholder
{t('placeholder')}
</Button>
)}
{!userRow.isOwner && (
@@ -190,7 +192,7 @@ export default function UsersTable({ users: u }: UsersTableProps) {
variant={"outlinePrimary"}
className="ml-2"
>
Manage
{t('manage')}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</Link>
@@ -208,10 +210,10 @@ export default function UsersTable({ users: u }: UsersTableProps) {
.catch((e) => {
toast({
variant: "destructive",
title: "Failed to remove user",
title: t('userErrorOrgRemove'),
description: formatAxiosError(
e,
"An error occurred while removing the user."
t('userErrorOrgRemoveDescription')
)
});
});
@@ -219,8 +221,8 @@ export default function UsersTable({ users: u }: UsersTableProps) {
if (res && res.status === 200) {
toast({
variant: "default",
title: "User removed",
description: `The user ${selectedUser.email} has been removed from the organization.`
title: t('userOrgRemoved'),
description: t('userOrgRemovedDescription', {email: selectedUser.email || ""})
});
setUsers((prev) =>
@@ -242,29 +244,19 @@ export default function UsersTable({ users: u }: UsersTableProps) {
dialog={
<div className="space-y-4">
<p>
Are you sure you want to remove{" "}
<b>
{selectedUser?.email ||
selectedUser?.name ||
selectedUser?.username}
</b>{" "}
from the organization?
{t('userQuestionOrgRemove', {email: selectedUser?.email || selectedUser?.name || selectedUser?.username || ""})}
</p>
<p>
Once removed, this user will no longer have access
to the organization. You can always re-invite them
later, but they will need to accept the invitation
again.
{t('userMessageOrgRemove')}
</p>
<p>
To confirm, please type the name of the of the user
below.
{t('userMessageOrgConfirm')}
</p>
</div>
}
buttonText="Confirm Remove User"
buttonText={t('userRemoveOrgConfirm')}
onConfirm={removeUser}
string={
selectedUser?.email ||
@@ -272,7 +264,7 @@ export default function UsersTable({ users: u }: UsersTableProps) {
selectedUser?.username ||
""
}
title="Remove User from Organization"
title={t('userRemoveOrg')}
/>
<UsersDataTable

View File

@@ -40,11 +40,7 @@ import {
import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
const formSchema = z.object({
username: z.string(),
roleId: z.string().min(1, { message: "Please select a role" })
});
import { useTranslations } from "next-intl";
export default function AccessControlsPage() {
const { orgUser: user } = userOrgUserContext();
@@ -56,6 +52,13 @@ export default function AccessControlsPage() {
const [loading, setLoading] = useState(false);
const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]);
const t = useTranslations();
const formSchema = z.object({
username: z.string(),
roleId: z.string().min(1, { message: t('accessRoleSelectPlease') })
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
@@ -72,10 +75,10 @@ export default function AccessControlsPage() {
console.error(e);
toast({
variant: "destructive",
title: "Failed to fetch roles",
title: t('accessRoleErrorFetch'),
description: formatAxiosError(
e,
"An error occurred while fetching the roles"
t('accessRoleErrorFetchDescription')
)
});
});
@@ -100,10 +103,10 @@ export default function AccessControlsPage() {
.catch((e) => {
toast({
variant: "destructive",
title: "Failed to add user to role",
title: t('accessRoleErrorAdd'),
description: formatAxiosError(
e,
"An error occurred while adding user to the role."
t('accessRoleErrorAddDescription')
)
});
});
@@ -111,8 +114,8 @@ export default function AccessControlsPage() {
if (res && res.status === 200) {
toast({
variant: "default",
title: "User saved",
description: "The user has been updated."
title: t('userSaved'),
description: t('userSavedDescription')
});
}
@@ -123,10 +126,9 @@ export default function AccessControlsPage() {
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>Access Controls</SettingsSectionTitle>
<SettingsSectionTitle>{t('accessControls')}</SettingsSectionTitle>
<SettingsSectionDescription>
Manage what this user can access and do in the
organization
{t('accessControlsDescription')}
</SettingsSectionDescription>
</SettingsSectionHeader>
@@ -143,14 +145,14 @@ export default function AccessControlsPage() {
name="roleId"
render={({ field }) => (
<FormItem>
<FormLabel>Role</FormLabel>
<FormLabel>{t('role')}</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select role" />
<SelectValue placeholder={t('accessRoleSelect')} />
</SelectTrigger>
</FormControl>
<SelectContent>
@@ -180,7 +182,7 @@ export default function AccessControlsPage() {
disabled={loading}
form="access-controls-form"
>
Save Access Controls
{t('accessControlsSubmit')}
</Button>
</SettingsSectionFooter>
</SettingsSection>

View File

@@ -15,6 +15,7 @@ import {
import Link from "next/link";
import { cache } from "react";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { getTranslations } from 'next-intl/server';
interface UserLayoutProps {
children: React.ReactNode;
@@ -26,6 +27,8 @@ export default async function UserLayoutProps(props: UserLayoutProps) {
const { children } = props;
const t = await getTranslations();
let user = null;
try {
const getOrgUser = cache(async () =>
@@ -42,7 +45,7 @@ export default async function UserLayoutProps(props: UserLayoutProps) {
const navItems = [
{
title: "Access Controls",
title: t('accessControls'),
href: "/{orgId}/settings/access/users/{userId}/access-controls"
}
];
@@ -51,7 +54,7 @@ export default async function UserLayoutProps(props: UserLayoutProps) {
<>
<SettingsSectionTitle
title={`${user?.email}`}
description="Manage the settings on this user"
description={t('userDescription2')}
/>
<OrgUserProvider orgUser={user}>
<HorizontalTabs items={navItems}>

View File

@@ -44,6 +44,7 @@ import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { Checkbox } from "@app/components/ui/checkbox";
import { ListIdpsResponse } from "@server/routers/idp";
import { useTranslations } from "next-intl";
type UserType = "internal" | "oidc";
@@ -59,38 +60,12 @@ interface IdpOption {
type: string;
}
const internalFormSchema = z.object({
email: z.string().email({ message: "Invalid email address" }),
validForHours: z.string().min(1, { message: "Please select a duration" }),
roleId: z.string().min(1, { message: "Please select a role" })
});
const externalFormSchema = z.object({
username: z.string().min(1, { message: "Username is required" }),
email: z
.string()
.email({ message: "Invalid email address" })
.optional()
.or(z.literal("")),
name: z.string().optional(),
roleId: z.string().min(1, { message: "Please select a role" }),
idpId: z.string().min(1, { message: "Please select an identity provider" })
});
const formatIdpType = (type: string) => {
switch (type.toLowerCase()) {
case "oidc":
return "Generic OAuth2/OIDC provider.";
default:
return type;
}
};
export default function Page() {
const { orgId } = useParams();
const router = useRouter();
const { env } = useEnvContext();
const api = createApiClient({ env });
const t = useTranslations();
const [userType, setUserType] = useState<UserType | null>("internal");
const [inviteLink, setInviteLink] = useState<string | null>(null);
@@ -102,14 +77,41 @@ export default function Page() {
const [selectedIdp, setSelectedIdp] = useState<IdpOption | null>(null);
const [dataLoaded, setDataLoaded] = useState(false);
const internalFormSchema = z.object({
email: z.string().email({ message: t('emailInvalid') }),
validForHours: z.string().min(1, { message: t('inviteValidityDuration') }),
roleId: z.string().min(1, { message: t('accessRoleSelectPlease') })
});
const externalFormSchema = z.object({
username: z.string().min(1, { message: t('usernameRequired') }),
email: z
.string()
.email({ message: t('emailInvalid') })
.optional()
.or(z.literal("")),
name: z.string().optional(),
roleId: z.string().min(1, { message: t('accessRoleSelectPlease') }),
idpId: z.string().min(1, { message: t('idpSelectPlease') })
});
const formatIdpType = (type: string) => {
switch (type.toLowerCase()) {
case "oidc":
return t('idpGenericOidc');
default:
return type;
}
};
const validFor = [
{ hours: 24, name: "1 day" },
{ hours: 48, name: "2 days" },
{ hours: 72, name: "3 days" },
{ hours: 96, name: "4 days" },
{ hours: 120, name: "5 days" },
{ hours: 144, name: "6 days" },
{ hours: 168, name: "7 days" }
{ hours: 24, name: t('day', {count: 1}) },
{ hours: 48, name: t('day', {count: 2}) },
{ hours: 72, name: t('day', {count: 3}) },
{ hours: 96, name: t('day', {count: 4}) },
{ hours: 120, name: t('day', {count: 5}) },
{ hours: 144, name: t('day', {count: 6}) },
{ hours: 168, name: t('day', {count: 7}) }
];
const internalForm = useForm<z.infer<typeof internalFormSchema>>({
@@ -155,10 +157,10 @@ export default function Page() {
console.error(e);
toast({
variant: "destructive",
title: "Failed to fetch roles",
title: t('accessRoleErrorFetch'),
description: formatAxiosError(
e,
"An error occurred while fetching the roles"
t('accessRoleErrorFetchDescription')
)
});
});
@@ -178,10 +180,10 @@ export default function Page() {
console.error(e);
toast({
variant: "destructive",
title: "Failed to fetch identity providers",
title: t('idpErrorFetch'),
description: formatAxiosError(
e,
"An error occurred while fetching identity providers"
t('idpErrorFetchDescription')
)
});
});
@@ -218,17 +220,16 @@ export default function Page() {
if (e.response?.status === 409) {
toast({
variant: "destructive",
title: "User Already Exists",
description:
"This user is already a member of the organization."
title: t('userErrorExists'),
description: t('userErrorExistsDescription')
});
} else {
toast({
variant: "destructive",
title: "Failed to invite user",
title: t('inviteError'),
description: formatAxiosError(
e,
"An error occurred while inviting the user"
t('inviteErrorDescription')
)
});
}
@@ -238,8 +239,8 @@ export default function Page() {
setInviteLink(res.data.data.inviteLink);
toast({
variant: "default",
title: "User invited",
description: "The user has been successfully invited."
title: t('userInvited'),
description: t('userInvitedDescription')
});
setExpiresInDays(parseInt(values.validForHours) / 24);
@@ -265,10 +266,10 @@ export default function Page() {
.catch((e) => {
toast({
variant: "destructive",
title: "Failed to create user",
title: t('userErrorCreate'),
description: formatAxiosError(
e,
"An error occurred while creating the user"
t('userErrorCreateDescription')
)
});
});
@@ -276,8 +277,8 @@ export default function Page() {
if (res && res.status === 201) {
toast({
variant: "default",
title: "User created",
description: "The user has been successfully created."
title: t('userCreated'),
description: t('userCreatedDescription')
});
router.push(`/${orgId}/settings/access/users`);
}
@@ -288,13 +289,13 @@ export default function Page() {
const userTypes: ReadonlyArray<UserTypeOption> = [
{
id: "internal",
title: "Internal User",
description: "Invite a user to join your organization directly."
title: t('userTypeInternal'),
description: t('userTypeInternalDescription')
},
{
id: "oidc",
title: "External User",
description: "Create a user with an external identity provider."
title: t('userTypeExternal'),
description: t('userTypeExternalDescription')
}
];
@@ -302,8 +303,8 @@ export default function Page() {
<>
<div className="flex justify-between">
<HeaderTitle
title="Create User"
description="Follow the steps below to create a new user"
title={t('accessUserCreate')}
description={t('accessUserCreateDescription')}
/>
<Button
variant="outline"
@@ -311,7 +312,7 @@ export default function Page() {
router.push(`/${orgId}/settings/access/users`);
}}
>
See All Users
{t('userSeeAll')}
</Button>
</div>
@@ -320,10 +321,10 @@ export default function Page() {
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
User Type
{t('userTypeTitle')}
</SettingsSectionTitle>
<SettingsSectionDescription>
Determine how you want to create the user
{t('userTypeDescription')}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
@@ -349,10 +350,10 @@ export default function Page() {
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
User Information
{t('userSettings')}
</SettingsSectionTitle>
<SettingsSectionDescription>
Enter the details for the new user
{t('userSettingsDescription')}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
@@ -373,7 +374,7 @@ export default function Page() {
render={({ field }) => (
<FormItem>
<FormLabel>
Email
{t('email')}
</FormLabel>
<FormControl>
<Input
@@ -402,8 +403,7 @@ export default function Page() {
htmlFor="send-email"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Send invite email to
user
{t('inviteEmailSent')}
</label>
</div>
)}
@@ -416,7 +416,7 @@ export default function Page() {
render={({ field }) => (
<FormItem>
<FormLabel>
Valid For
{t('inviteValid')}
</FormLabel>
<Select
onValueChange={
@@ -428,7 +428,7 @@ export default function Page() {
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select duration" />
<SelectValue placeholder={t('selectDuration')} />
</SelectTrigger>
</FormControl>
<SelectContent>
@@ -463,7 +463,7 @@ export default function Page() {
render={({ field }) => (
<FormItem>
<FormLabel>
Role
{t('role')}
</FormLabel>
<Select
onValueChange={
@@ -472,7 +472,7 @@ export default function Page() {
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select role" />
<SelectValue placeholder={t('accessRoleSelect')} />
</SelectTrigger>
</FormControl>
<SelectContent>
@@ -503,37 +503,16 @@ export default function Page() {
<div className="max-w-md space-y-4">
{sendEmail && (
<p>
An email has
been sent to the
user with the
access link
below. They must
access the link
to accept the
invitation.
{t('inviteEmailSentDescription')}
</p>
)}
{!sendEmail && (
<p>
The user has
been invited.
They must access
the link below
to accept the
invitation.
{t('inviteSentDescription')}
</p>
)}
<p>
The invite will
expire in{" "}
<b>
{expiresInDays}{" "}
{expiresInDays ===
1
? "day"
: "days"}
</b>
.
{t('inviteExpiresIn', {days: expiresInDays})}
</p>
<CopyTextBox
text={inviteLink}
@@ -554,20 +533,16 @@ export default function Page() {
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
Identity Provider
{t('idpTitle')}
</SettingsSectionTitle>
<SettingsSectionDescription>
Select the identity provider for the
external user
{t('idpSelect')}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
{idps.length === 0 ? (
<p className="text-muted-foreground">
No identity providers are
configured. Please configure an
identity provider before creating
external users.
{t('idpNotConfigured')}
</p>
) : (
<Form {...externalForm}>
@@ -621,10 +596,10 @@ export default function Page() {
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
User Information
{t('userSettings')}
</SettingsSectionTitle>
<SettingsSectionDescription>
Enter the details for the new user
{t('userSettingsDescription')}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
@@ -645,7 +620,7 @@ export default function Page() {
render={({ field }) => (
<FormItem>
<FormLabel>
Username
{t('username')}
</FormLabel>
<FormControl>
<Input
@@ -653,15 +628,7 @@ export default function Page() {
/>
</FormControl>
<p className="text-sm text-muted-foreground mt-1">
This must
match the
unique
username
that exists
in the
selected
identity
provider.
{t('usernameUniq')}
</p>
<FormMessage />
</FormItem>
@@ -676,8 +643,7 @@ export default function Page() {
render={({ field }) => (
<FormItem>
<FormLabel>
Email
(Optional)
{t('emailOptional')}
</FormLabel>
<FormControl>
<Input
@@ -697,8 +663,7 @@ export default function Page() {
render={({ field }) => (
<FormItem>
<FormLabel>
Name
(Optional)
{t('nameOptional')}
</FormLabel>
<FormControl>
<Input
@@ -718,7 +683,7 @@ export default function Page() {
render={({ field }) => (
<FormItem>
<FormLabel>
Role
{t('role')}
</FormLabel>
<Select
onValueChange={
@@ -727,7 +692,7 @@ export default function Page() {
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select role" />
<SelectValue placeholder={t('accessRoleSelect')} />
</SelectTrigger>
</FormControl>
<SelectContent>
@@ -771,7 +736,7 @@ export default function Page() {
router.push(`/${orgId}/settings/access/users`);
}}
>
Cancel
{t('cancel')}
</Button>
{userType && dataLoaded && (
<Button
@@ -783,7 +748,7 @@ export default function Page() {
(userType === "internal" && inviteLink !== null)
}
>
Create User
{t('accessUserCreate')}
</Button>
)}
</div>

View File

@@ -10,6 +10,7 @@ import UserProvider from "@app/providers/UserProvider";
import { verifySession } from "@app/lib/auth/verifySession";
import AccessPageHeaderAndNav from "../AccessPageHeaderAndNav";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { getTranslations } from 'next-intl/server';
type UsersPageProps = {
params: Promise<{ orgId: string }>;
@@ -22,6 +23,7 @@ export default async function UsersPage(props: UsersPageProps) {
const getUser = cache(verifySession);
const user = await getUser();
const t = await getTranslations();
let users: ListUsersResponse["users"] = [];
let hasInvitations = false;
@@ -76,9 +78,9 @@ export default async function UsersPage(props: UsersPageProps) {
email: user.email,
type: user.type,
idpId: user.idpId,
idpName: user.idpName || "Internal",
status: "Confirmed",
role: user.isOwner ? "Owner" : user.roleName || "Member",
idpName: user.idpName || t('idpNameInternal'),
status: t('userConfirmed'),
role: user.isOwner ? t('accessRoleOwner') : user.roleName || t('accessRoleMember'),
isOwner: user.isOwner || false
};
});
@@ -86,8 +88,8 @@ export default async function UsersPage(props: UsersPageProps) {
return (
<>
<SettingsSectionTitle
title="Manage Users"
description="Invite users and add them to roles to manage access to your organization"
title={t('accessUsersManage')}
description={t('accessUsersDescription')}
/>
<UserProvider user={user!}>
<OrgProvider org={org}>

View File

@@ -2,6 +2,7 @@
import { DataTable } from "@app/components/ui/data-table";
import { ColumnDef } from "@tanstack/react-table";
import { useTranslations } from "next-intl";
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
@@ -14,15 +15,18 @@ export function OrgApiKeysDataTable<TData, TValue>({
columns,
data
}: DataTableProps<TData, TValue>) {
const t = useTranslations();
return (
<DataTable
columns={columns}
data={data}
title="API Keys"
searchPlaceholder="Search API keys..."
title={t('apiKeys')}
searchPlaceholder={t('searchApiKeys')}
searchColumn="name"
onAdd={addApiKey}
addButtonText="Generate API Key"
addButtonText={t('apiKeysAdd')}
/>
);
}

View File

@@ -19,6 +19,7 @@ import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import moment from "moment";
import { useTranslations } from "next-intl";
export type OrgApiKeyRow = {
id: string;
@@ -44,14 +45,16 @@ export default function OrgApiKeysTable({
const api = createApiClient(useEnvContext());
const t = useTranslations();
const deleteSite = (apiKeyId: string) => {
api.delete(`/org/${orgId}/api-key/${apiKeyId}`)
.catch((e) => {
console.error("Error deleting API key", e);
console.error(t('apiKeysErrorDelete'), e);
toast({
variant: "destructive",
title: "Error deleting API key",
description: formatAxiosError(e, "Error deleting API key")
title: t('apiKeysErrorDelete'),
description: formatAxiosError(e, t('apiKeysErrorDeleteMessage'))
});
})
.then(() => {
@@ -75,7 +78,7 @@ export default function OrgApiKeysTable({
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<span className="sr-only">{t('openMenu')}</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
@@ -85,7 +88,7 @@ export default function OrgApiKeysTable({
setSelected(apiKeyROw);
}}
>
<span>View settings</span>
<span>{t('viewSettings')}</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
@@ -93,7 +96,7 @@ export default function OrgApiKeysTable({
setIsDeleteModalOpen(true);
}}
>
<span className="text-red-500">Delete</span>
<span className="text-red-500">{t('delete')}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -110,7 +113,7 @@ export default function OrgApiKeysTable({
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Name
{t('name')}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
@@ -118,7 +121,7 @@ export default function OrgApiKeysTable({
},
{
accessorKey: "key",
header: "Key",
header: t('key'),
cell: ({ row }) => {
const r = row.original;
return <span className="font-mono">{r.key}</span>;
@@ -126,7 +129,7 @@ export default function OrgApiKeysTable({
},
{
accessorKey: "createdAt",
header: "Created At",
header: t('createdAt'),
cell: ({ row }) => {
const r = row.original;
return <span>{moment(r.createdAt).format("lll")} </span>;
@@ -140,7 +143,7 @@ export default function OrgApiKeysTable({
<div className="flex items-center justify-end">
<Link href={`/${orgId}/settings/api-keys/${r.id}`}>
<Button variant={"outlinePrimary"} className="ml-2">
Edit
{t('edit')}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</Link>
@@ -162,28 +165,24 @@ export default function OrgApiKeysTable({
dialog={
<div className="space-y-4">
<p>
Are you sure you want to remove the API key{" "}
<b>{selected?.name || selected?.id}</b> from the
organization?
{t('apiKeysQuestionRemove', {selectedApiKey: selected?.name || selected?.id})}
</p>
<p>
<b>
Once removed, the API key will no longer be
able to be used.
{t('apiKeysMessageRemove')}
</b>
</p>
<p>
To confirm, please type the name of the API key
below.
{t('apiKeysMessageConfirm')}
</p>
</div>
}
buttonText="Confirm Delete API Key"
buttonText={t('apiKeysDeleteConfirm')}
onConfirm={async () => deleteSite(selected!.id)}
string={selected.name}
title="Delete API Key"
title={t('apiKeysDelete')}
/>
)}

View File

@@ -15,6 +15,7 @@ import {
import { GetApiKeyResponse } from "@server/routers/apiKeys";
import ApiKeyProvider from "@app/providers/ApiKeyProvider";
import { HorizontalTabs } from "@app/components/HorizontalTabs";
import { getTranslations } from 'next-intl/server';
interface SettingsLayoutProps {
children: React.ReactNode;
@@ -23,6 +24,7 @@ interface SettingsLayoutProps {
export default async function SettingsLayout(props: SettingsLayoutProps) {
const params = await props.params;
const t = await getTranslations();
const { children } = props;
@@ -40,14 +42,14 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
const navItems = [
{
title: "Permissions",
title: t('apiKeysPermissionsTitle'),
href: "/{orgId}/settings/api-keys/{apiKeyId}/permissions"
}
];
return (
<>
<SettingsSectionTitle title={`${apiKey?.name} Settings`} />
<SettingsSectionTitle title={t('apiKeysSettings', {apiKeyName: apiKey?.name})} />
<ApiKeyProvider apiKey={apiKey}>
<HorizontalTabs items={navItems}>{children}</HorizontalTabs>

View File

@@ -18,12 +18,15 @@ import { ListApiKeyActionsResponse } from "@server/routers/apiKeys";
import { AxiosResponse } from "axios";
import { useParams } from "next/navigation";
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
export default function Page() {
const { env } = useEnvContext();
const api = createApiClient({ env });
const { orgId, apiKeyId } = useParams();
const t = useTranslations();
const [loadingPage, setLoadingPage] = useState<boolean>(true);
const [selectedPermissions, setSelectedPermissions] = useState<
Record<string, boolean>
@@ -42,10 +45,10 @@ export default function Page() {
.catch((e) => {
toast({
variant: "destructive",
title: "Error loading API key actions",
title: t('apiKeysPermissionsErrorLoadingActions'),
description: formatAxiosError(
e,
"Error loading API key actions"
t('apiKeysPermissionsErrorLoadingActions')
)
});
});
@@ -76,18 +79,18 @@ export default function Page() {
)
})
.catch((e) => {
console.error("Error setting permissions", e);
console.error(t('apiKeysErrorSetPermission'), e);
toast({
variant: "destructive",
title: "Error setting permissions",
title: t('apiKeysErrorSetPermission'),
description: formatAxiosError(e)
});
});
if (actionsRes && actionsRes.status === 200) {
toast({
title: "Permissions updated",
description: "The permissions have been updated."
title: t('apiKeysPermissionsUpdated'),
description: t('apiKeysPermissionsUpdatedDescription')
});
}
@@ -101,10 +104,10 @@ export default function Page() {
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
Permissions
{t('apiKeysPermissionsGeneralSettings')}
</SettingsSectionTitle>
<SettingsSectionDescription>
Determine what this API key can do
{t('apiKeysPermissionsGeneralSettingsDescription')}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
@@ -121,7 +124,7 @@ export default function Page() {
loading={loadingSavePermissions}
disabled={loadingSavePermissions}
>
Save Permissions
{t('apiKeysPermissionsSave')}
</Button>
</SettingsSectionFooter>
</SettingsSectionBody>

View File

@@ -55,41 +55,14 @@ import moment from "moment";
import CopyCodeBox from "@server/emails/templates/components/CopyCodeBox";
import CopyTextBox from "@app/components/CopyTextBox";
import PermissionsSelectBox from "@app/components/PermissionsSelectBox";
const createFormSchema = z.object({
name: z
.string()
.min(2, {
message: "Name must be at least 2 characters."
})
.max(255, {
message: "Name must not be longer than 255 characters."
})
});
type CreateFormValues = z.infer<typeof createFormSchema>;
const copiedFormSchema = z
.object({
copied: z.boolean()
})
.refine(
(data) => {
return data.copied;
},
{
message: "You must confirm that you have copied the API key.",
path: ["copied"]
}
);
type CopiedFormValues = z.infer<typeof copiedFormSchema>;
import { useTranslations } from "next-intl";
export default function Page() {
const { env } = useEnvContext();
const api = createApiClient({ env });
const { orgId } = useParams();
const router = useRouter();
const t = useTranslations();
const [loadingPage, setLoadingPage] = useState(true);
const [createLoading, setCreateLoading] = useState(false);
@@ -98,6 +71,35 @@ export default function Page() {
Record<string, boolean>
>({});
const createFormSchema = z.object({
name: z
.string()
.min(2, {
message: t('nameMin', {len: 2})
})
.max(255, {
message: t('nameMax', {len: 255})
})
});
type CreateFormValues = z.infer<typeof createFormSchema>;
const copiedFormSchema = z
.object({
copied: z.boolean()
})
.refine(
(data) => {
return data.copied;
},
{
message: t('apiKeysConfirmCopy2'),
path: ["copied"]
}
);
type CopiedFormValues = z.infer<typeof copiedFormSchema>;
const form = useForm<CreateFormValues>({
resolver: zodResolver(createFormSchema),
defaultValues: {
@@ -126,7 +128,7 @@ export default function Page() {
.catch((e) => {
toast({
variant: "destructive",
title: "Error creating API key",
title: t('apiKeysErrorCreate'),
description: formatAxiosError(e)
});
});
@@ -147,10 +149,10 @@ export default function Page() {
)
})
.catch((e) => {
console.error("Error setting permissions", e);
console.error(t('apiKeysErrorSetPermission'), e);
toast({
variant: "destructive",
title: "Error setting permissions",
title: t('apiKeysErrorSetPermission'),
description: formatAxiosError(e)
});
});
@@ -189,8 +191,8 @@ export default function Page() {
<>
<div className="flex justify-between">
<HeaderTitle
title="Generate API Key"
description="Generate a new API key for your organization"
title={t('apiKeysCreate')}
description={t('apiKeysCreateDescription')}
/>
<Button
variant="outline"
@@ -198,7 +200,7 @@ export default function Page() {
router.push(`/${orgId}/settings/api-keys`);
}}
>
See All API Keys
{t('apiKeysSeeAll')}
</Button>
</div>
@@ -210,7 +212,7 @@ export default function Page() {
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
API Key Information
{t('apiKeysTitle')}
</SettingsSectionTitle>
</SettingsSectionHeader>
<SettingsSectionBody>
@@ -226,7 +228,7 @@ export default function Page() {
render={({ field }) => (
<FormItem>
<FormLabel>
Name
{t('name')}
</FormLabel>
<FormControl>
<Input
@@ -247,10 +249,10 @@ export default function Page() {
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
Permissions
{t('apiKeysGeneralSettings')}
</SettingsSectionTitle>
<SettingsSectionDescription>
Determine what this API key can do
{t('apiKeysGeneralSettingsDescription')}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
@@ -269,14 +271,14 @@ export default function Page() {
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
Your API Key
{t('apiKeysList')}
</SettingsSectionTitle>
</SettingsSectionHeader>
<SettingsSectionBody>
<InfoSections cols={2}>
<InfoSection>
<InfoSectionTitle>
Name
{t('name')}
</InfoSectionTitle>
<InfoSectionContent>
<CopyToClipboard
@@ -286,7 +288,7 @@ export default function Page() {
</InfoSection>
<InfoSection>
<InfoSectionTitle>
Created
{t('created')}
</InfoSectionTitle>
<InfoSectionContent>
{moment(
@@ -299,17 +301,15 @@ export default function Page() {
<Alert variant="neutral">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
Save Your API Key
{t('apiKeysSave')}
</AlertTitle>
<AlertDescription>
You will only be able to see this
once. Make sure to copy it to a
secure place.
{t('apiKeysSaveDescription')}
</AlertDescription>
</Alert>
<h4 className="font-semibold">
Your API key is:
{t('apiKeysInfo')}
</h4>
<CopyTextBox
@@ -347,8 +347,7 @@ export default function Page() {
htmlFor="terms"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
I have copied
the API key
{t('apiKeysConfirmCopy')}
</label>
</div>
<FormMessage />
@@ -372,7 +371,7 @@ export default function Page() {
router.push(`/${orgId}/settings/api-keys`);
}}
>
Cancel
{t('cancel')}
</Button>
)}
{!apiKey && (
@@ -384,7 +383,7 @@ export default function Page() {
form.handleSubmit(onSubmit)();
}}
>
Generate
{t('generate')}
</Button>
)}
@@ -395,7 +394,7 @@ export default function Page() {
copiedForm.handleSubmit(onCopiedSubmit)();
}}
>
Done
{t('done')}
</Button>
)}
</div>

View File

@@ -4,6 +4,7 @@ import { AxiosResponse } from "axios";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import OrgApiKeysTable, { OrgApiKeyRow } from "./OrgApiKeysTable";
import { ListOrgApiKeysResponse } from "@server/routers/apiKeys";
import { getTranslations } from 'next-intl/server';
type ApiKeyPageProps = {
params: Promise<{ orgId: string }>;
@@ -13,6 +14,8 @@ export const dynamic = "force-dynamic";
export default async function ApiKeysPage(props: ApiKeyPageProps) {
const params = await props.params;
const t = await getTranslations();
let apiKeys: ListOrgApiKeysResponse["apiKeys"] = [];
try {
const res = await internal.get<AxiosResponse<ListOrgApiKeysResponse>>(
@@ -34,8 +37,8 @@ export default async function ApiKeysPage(props: ApiKeyPageProps) {
return (
<>
<SettingsSectionTitle
title="Manage API Keys"
description="API keys are used to authenticate with the integration API"
title={t('apiKeysManage')}
description={t('apiKeysDescription')}
/>
<OrgApiKeysTable apiKeys={rows} orgId={params.orgId} />

View File

@@ -10,6 +10,7 @@ import { GetOrgUserResponse } from "@server/routers/user";
import { AxiosResponse } from "axios";
import { redirect } from "next/navigation";
import { cache } from "react";
import { getTranslations } from 'next-intl/server';
type GeneralSettingsProps = {
children: React.ReactNode;
@@ -57,9 +58,11 @@ export default async function GeneralSettingsPage({
redirect(`/${orgId}`);
}
const t = await getTranslations();
const navItems = [
{
title: "General",
title: t('general'),
href: `/{orgId}/settings/general`,
},
];
@@ -69,8 +72,8 @@ export default async function GeneralSettingsPage({
<OrgProvider org={org}>
<OrgUserProvider orgUser={orgUser}>
<SettingsSectionTitle
title="General"
description="Configure your organization's general settings"
title={t('general')}
description={t('orgSettingsDescription')}
/>
<HorizontalTabs items={navItems}>

View File

@@ -35,6 +35,7 @@ import {
SettingsSectionFooter
} from "@app/components/Settings";
import { useUserContext } from "@app/hooks/useUserContext";
import { useTranslations } from 'next-intl';
// Updated schema to include subnet field
const GeneralFormSchema = z.object({
@@ -51,6 +52,7 @@ export default function GeneralPage() {
const { org } = useOrgContext();
const api = createApiClient(useEnvContext());
const { user } = useUserContext();
const t = useTranslations();
const [loadingDelete, setLoadingDelete] = useState(false);
const [loadingSave, setLoadingSave] = useState(false);
@@ -71,8 +73,8 @@ export default function GeneralPage() {
`/org/${org?.org.orgId}`
);
toast({
title: "Organization deleted",
description: "The organization and its data has been deleted."
title: t('orgDeleted'),
description: t('orgDeletedMessage')
});
if (res.status === 200) {
pickNewOrgAndNavigate();
@@ -81,11 +83,8 @@ export default function GeneralPage() {
console.error(err);
toast({
variant: "destructive",
title: "Failed to delete org",
description: formatAxiosError(
err,
"An error occurred while deleting the org."
)
title: t('orgErrorDelete'),
description: formatAxiosError(err, t('orgErrorDeleteMessage'))
});
} finally {
setLoadingDelete(false);
@@ -112,11 +111,8 @@ export default function GeneralPage() {
console.error(err);
toast({
variant: "destructive",
title: "Failed to fetch orgs",
description: formatAxiosError(
err,
"An error occurred while listing your orgs"
)
title: t('orgErrorFetch'),
description: formatAxiosError(err, t('orgErrorFetchMessage'))
});
}
}
@@ -130,19 +126,16 @@ export default function GeneralPage() {
})
.then(() => {
toast({
title: "Organization updated",
description: "The organization has been updated."
title: t('orgUpdated'),
description: t('orgUpdatedDescription')
});
router.refresh();
})
.catch((e) => {
toast({
variant: "destructive",
title: "Failed to update org",
description: formatAxiosError(
e,
"An error occurred while updating the org."
)
title: t('orgErrorUpdate'),
description: formatAxiosError(e, t('orgErrorUpdateMessage'))
});
})
.finally(() => {
@@ -160,30 +153,28 @@ export default function GeneralPage() {
dialog={
<div>
<p className="mb-2">
Are you sure you want to delete the organization{" "}
<b>{org?.org.name}?</b>
{t('orgQuestionRemove', {selectedOrg: org?.org.name})}
</p>
<p className="mb-2">
This action is irreversible and will delete all
associated data.
{t('orgMessageRemove')}
</p>
<p>
To confirm, type the name of the organization below.
{t('orgMessageConfirm')}
</p>
</div>
}
buttonText="Confirm Delete Organization"
buttonText={t('orgDeleteConfirm')}
onConfirm={deleteOrg}
string={org?.org.name || ""}
title="Delete Organization"
title={t('orgDelete')}
/>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
Organization Settings
{t('orgGeneralSettings')}
</SettingsSectionTitle>
<SettingsSectionDescription>
Manage your organization details and configuration
{t('orgGeneralSettingsDescription')}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
@@ -199,14 +190,13 @@ export default function GeneralPage() {
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormLabel>{t('name')}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
<FormDescription>
This is the display name of the
organization.
{t('orgDisplayName')}
</FormDescription>
</FormItem>
)}
@@ -242,16 +232,15 @@ export default function GeneralPage() {
loading={loadingSave}
disabled={loadingSave}
>
Save General Settings
{t('saveGeneralSettings')}
</Button>
</SettingsSectionFooter>
</SettingsSection>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>Danger Zone</SettingsSectionTitle>
<SettingsSectionTitle>{t('orgDangerZone')}</SettingsSectionTitle>
<SettingsSectionDescription>
Once you delete this org, there is no going back. Please
be certain.
{t('orgDangerZoneDescription')}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionFooter>
@@ -262,7 +251,7 @@ export default function GeneralPage() {
loading={loadingDelete}
disabled={loadingDelete}
>
Delete Organization Data
{t('orgDelete')}
</Button>
</SettingsSectionFooter>
</SettingsSection>

View File

@@ -19,6 +19,7 @@ import UserProvider from "@app/providers/UserProvider";
import { Layout } from "@app/components/Layout";
import { SidebarNavItem, SidebarNavProps } from "@app/components/SidebarNav";
import { orgNavItems } from "@app/app/navigation";
import { getTranslations } from 'next-intl/server';
export const dynamic = "force-dynamic";
@@ -79,6 +80,8 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
const cookie = await authCookieHeader();
const t = await getTranslations();
try {
const getOrgUser = cache(() =>
internal.get<AxiosResponse<GetOrgUserResponse>>(
@@ -89,7 +92,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
const orgUser = await getOrgUser();
if (!orgUser.data.data.isAdmin && !orgUser.data.data.isOwner) {
throw new Error("User is not an admin or owner");
throw new Error(t('userErrorNotAdminOrOwner'));
}
} catch {
redirect(`/${params.orgId}`);

View File

@@ -2,6 +2,7 @@
import { ColumnDef } from "@tanstack/react-table";
import { DataTable } from "@app/components/ui/data-table";
import { useTranslations } from 'next-intl';
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
@@ -14,15 +15,18 @@ export function ResourcesDataTable<TData, TValue>({
data,
createResource
}: DataTableProps<TData, TValue>) {
const t = useTranslations();
return (
<DataTable
columns={columns}
data={data}
title="Resources"
searchPlaceholder="Search resources..."
title={t('resources')}
searchPlaceholder={t('resourcesSearch')}
searchColumn="name"
onAdd={createResource}
addButtonText="Add Resource"
addButtonText={t('resourceAdd')}
defaultSort={{
id: "name",
desc: false

View File

@@ -4,6 +4,7 @@ import React, { useState, useEffect } from "react";
import { Server, Lock, Key, Users, X, ArrowRight } from "lucide-react"; // Replace with actual imports
import { Card, CardContent } from "@app/components/ui/card";
import { Button } from "@app/components/ui/button";
import { useTranslations } from "next-intl";
export const ResourcesSplashCard = () => {
const [isDismissed, setIsDismissed] = useState(false);
@@ -22,6 +23,8 @@ export const ResourcesSplashCard = () => {
localStorage.setItem(key, "true");
};
const t = useTranslations();
if (isDismissed) {
return null;
}
@@ -31,7 +34,7 @@ export const ResourcesSplashCard = () => {
<button
onClick={handleDismiss}
className="absolute top-2 right-2 p-2"
aria-label="Dismiss"
aria-label={t('dismiss')}
>
<X className="w-5 h-5" />
</button>
@@ -39,24 +42,23 @@ export const ResourcesSplashCard = () => {
<div className="space-y-4">
<h3 className="text-xl font-semibold flex items-center gap-2">
<Server className="text-blue-500" />
Resources
{t('resources')}
</h3>
<p className="text-sm">
Resources are proxies to applications running on your private network. Create a resource for any HTTP/HTTPS or raw TCP/UDP service on your private network.
Each resource must be connected to a site to enable private, secure connectivity through an encrypted WireGuard tunnel.
{t('resourcesDescription')}
</p>
<ul className="text-sm text-muted-foreground space-y-2">
<li className="flex items-center gap-2">
<Lock className="text-green-500 w-4 h-4" />
Secure connectivity with WireGuard encryption
{t('resourcesWireGuardConnect')}
</li>
<li className="flex items-center gap-2">
<Key className="text-yellow-500 w-4 h-4" />
Configure multiple authentication methods
{t('resourcesMultipleAuthenticationMethods')}
</li>
<li className="flex items-center gap-2">
<Users className="text-purple-500 w-4 h-4" />
User and role-based access control
{t('resourcesUsersRolesAccess')}
</li>
</ul>
</div>

View File

@@ -31,6 +31,7 @@ import CopyToClipboard from "@app/components/CopyToClipboard";
import { Switch } from "@app/components/ui/switch";
import { AxiosResponse } from "axios";
import { UpdateResourceResponse } from "@server/routers/resource";
import { useTranslations } from 'next-intl';
export type ResourceRow = {
id: number;
@@ -53,6 +54,7 @@ type ResourcesTableProps = {
export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
const router = useRouter();
const t = useTranslations();
const api = createApiClient(useEnvContext());
@@ -63,11 +65,11 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
const deleteResource = (resourceId: number) => {
api.delete(`/resource/${resourceId}`)
.catch((e) => {
console.error("Error deleting resource", e);
console.error(t('resourceErrorDelte'), e);
toast({
variant: "destructive",
title: "Error deleting resource",
description: formatAxiosError(e, "Error deleting resource")
title: t('resourceErrorDelte'),
description: formatAxiosError(e, t('resourceErrorDelte'))
});
})
.then(() => {
@@ -87,11 +89,8 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
.catch((e) => {
toast({
variant: "destructive",
title: "Failed to toggle resource",
description: formatAxiosError(
e,
"An error occurred while updating the resource"
)
title: t('resourcesErrorUpdate'),
description: formatAxiosError(e, t('resourcesErrorUpdateDescription'))
});
});
}
@@ -108,7 +107,7 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<span className="sr-only">{t('openMenu')}</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
@@ -118,7 +117,7 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
href={`/${resourceRow.orgId}/settings/resources/${resourceRow.id}`}
>
<DropdownMenuItem>
View settings
{t('viewSettings')}
</DropdownMenuItem>
</Link>
<DropdownMenuItem
@@ -127,7 +126,7 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
setIsDeleteModalOpen(true);
}}
>
<span className="text-red-500">Delete</span>
<span className="text-red-500">{t('delete')}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -144,7 +143,7 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Name
{t('name')}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
@@ -160,7 +159,7 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Site
{t('site')}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
@@ -181,7 +180,7 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
},
{
accessorKey: "protocol",
header: "Protocol",
header: t('protocol'),
cell: ({ row }) => {
const resourceRow = row.original;
return <span>{resourceRow.protocol.toUpperCase()}</span>;
@@ -189,7 +188,7 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
},
{
accessorKey: "domain",
header: "Access",
header: t('access'),
cell: ({ row }) => {
const resourceRow = row.original;
return (
@@ -219,7 +218,7 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Authentication
{t('authentication')}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
@@ -231,12 +230,12 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
{resourceRow.authState === "protected" ? (
<span className="text-green-500 flex items-center space-x-2">
<ShieldCheck className="w-4 h-4" />
<span>Protected</span>
<span>{t('protected')}</span>
</span>
) : resourceRow.authState === "not_protected" ? (
<span className="text-yellow-500 flex items-center space-x-2">
<ShieldOff className="w-4 h-4" />
<span>Not Protected</span>
<span>{t('notProtected')}</span>
</span>
) : (
<span>-</span>
@@ -247,7 +246,7 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
},
{
accessorKey: "enabled",
header: "Enabled",
header: t('enabled'),
cell: ({ row }) => (
<Switch
defaultChecked={row.original.enabled}
@@ -267,7 +266,7 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
href={`/${resourceRow.orgId}/settings/resources/${resourceRow.id}`}
>
<Button variant={"outlinePrimary"} className="ml-2">
Edit
{t('edit')}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</Link>
@@ -289,30 +288,22 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
dialog={
<div>
<p className="mb-2">
Are you sure you want to remove the resource{" "}
<b>
{selectedResource?.name ||
selectedResource?.id}
</b>{" "}
from the organization?
{t('resourceQuestionRemove', {selectedResource: selectedResource?.name || selectedResource?.id})}
</p>
<p className="mb-2">
Once removed, the resource will no longer be
accessible. All targets attached to the resource
will be removed.
{t('resourceMessageRemove')}
</p>
<p>
To confirm, please type the name of the resource
below.
{t('resourceMessageConfirm')}
</p>
</div>
}
buttonText="Confirm Delete Resource"
buttonText={t('resourceDeleteConfirm')}
onConfirm={async () => deleteResource(selectedResource!.id)}
string={selectedResource.name}
title="Delete Resource"
title={t('resourceDelete')}
/>
)}

View File

@@ -13,6 +13,7 @@ import {
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useDockerSocket } from "@app/hooks/useDockerSocket";
import { useTranslations } from "next-intl";
type ResourceInfoBoxType = {};
@@ -21,6 +22,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
const api = createApiClient(useEnvContext());
const { isEnabled, isAvailable } = useDockerSocket(site!);
const t = useTranslations();
let fullUrl = `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`;
@@ -28,7 +30,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
<Alert>
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
Resource Information
{t('resourceInfo')}
</AlertTitle>
<AlertDescription className="mt-4">
<InfoSections cols={4}>
@@ -36,7 +38,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
<>
<InfoSection>
<InfoSectionTitle>
Authentication
{t('authentication')}
</InfoSectionTitle>
<InfoSectionContent>
{authInfo.password ||
@@ -45,12 +47,12 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
authInfo.whitelist ? (
<div className="flex items-start space-x-2 text-green-500">
<ShieldCheck className="w-4 h-4 mt-0.5" />
<span>Protected</span>
<span>{t('protected')}</span>
</div>
) : (
<div className="flex items-center space-x-2 text-yellow-500">
<ShieldOff className="w-4 h-4" />
<span>Not Protected</span>
<span>{t('notProtected')}</span>
</div>
)}
</InfoSectionContent>
@@ -65,7 +67,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
</InfoSectionContent>
</InfoSection>
<InfoSection>
<InfoSectionTitle>Site</InfoSectionTitle>
<InfoSectionTitle>{t('site')}</InfoSectionTitle>
<InfoSectionContent>
{resource.siteName}
</InfoSectionContent>
@@ -92,7 +94,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
) : (
<>
<InfoSection>
<InfoSectionTitle>Protocol</InfoSectionTitle>
<InfoSectionTitle>{t('protocol')}</InfoSectionTitle>
<InfoSectionContent>
<span>
{resource.protocol.toUpperCase()}
@@ -100,7 +102,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
</InfoSectionContent>
</InfoSection>
<InfoSection>
<InfoSectionTitle>Port</InfoSectionTitle>
<InfoSectionTitle>{t('port')}</InfoSectionTitle>
<InfoSectionContent>
<CopyToClipboard
text={resource.proxyPort!.toString()}
@@ -111,10 +113,10 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
</>
)}
<InfoSection>
<InfoSectionTitle>Visibility</InfoSectionTitle>
<InfoSectionTitle>{t('visibility')}</InfoSectionTitle>
<InfoSectionContent>
<span>
{resource.enabled ? "Enabled" : "Disabled"}
{resource.enabled ? t('enabled') : t('disabled')}
</span>
</InfoSectionContent>
</InfoSection>

View File

@@ -31,6 +31,7 @@ import { AxiosResponse } from "axios";
import { Resource } from "@server/db";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useTranslations } from "next-intl";
const setPasswordFormSchema = z.object({
password: z.string().min(4).max(100)
@@ -56,6 +57,7 @@ export default function SetResourcePasswordForm({
onSetPassword
}: SetPasswordFormProps) {
const api = createApiClient(useEnvContext());
const t = useTranslations();
const [loading, setLoading] = useState(false);
@@ -81,18 +83,17 @@ export default function SetResourcePasswordForm({
.catch((e) => {
toast({
variant: "destructive",
title: "Error setting resource password",
title: t('resourceErrorPasswordSetup'),
description: formatAxiosError(
e,
"An error occurred while setting the resource password"
t('resourceErrorPasswordSetupDescription')
)
});
})
.then(() => {
toast({
title: "Resource password set",
description:
"The resource password has been set successfully"
title: t('resourcePasswordSetup'),
description: t('resourcePasswordSetupDescription')
});
if (onSetPassword) {
@@ -114,9 +115,9 @@ export default function SetResourcePasswordForm({
>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>Set Password</CredenzaTitle>
<CredenzaTitle>{t('resourcePasswordSetupTitle')}</CredenzaTitle>
<CredenzaDescription>
Set a password to protect this resource
{t('resourcePasswordSetupTitleDescription')}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
@@ -131,7 +132,7 @@ export default function SetResourcePasswordForm({
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormLabel>{t('password')}</FormLabel>
<FormControl>
<Input
autoComplete="off"
@@ -148,7 +149,7 @@ export default function SetResourcePasswordForm({
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
<Button variant="outline">{t('close')}</Button>
</CredenzaClose>
<Button
type="submit"
@@ -156,7 +157,7 @@ export default function SetResourcePasswordForm({
loading={loading}
disabled={loading}
>
Enable Password Protection
{t('resourcePasswordSubmit')}
</Button>
</CredenzaFooter>
</CredenzaContent>

View File

@@ -36,6 +36,7 @@ import {
} from "@app/components/ui/input-otp";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useTranslations } from "next-intl";
const setPincodeFormSchema = z.object({
pincode: z.string().length(6)
@@ -69,6 +70,8 @@ export default function SetResourcePincodeForm({
defaultValues
});
const t = useTranslations();
useEffect(() => {
if (!open) {
return;
@@ -86,18 +89,17 @@ export default function SetResourcePincodeForm({
.catch((e) => {
toast({
variant: "destructive",
title: "Error setting resource PIN code",
title: t('resourceErrorPincodeSetup'),
description: formatAxiosError(
e,
"An error occurred while setting the resource PIN code"
t('resourceErrorPincodeSetupDescription')
)
});
})
.then(() => {
toast({
title: "Resource PIN code set",
description:
"The resource pincode has been set successfully"
title: t('resourcePincodeSetup'),
description: t('resourcePincodeSetupDescription')
});
if (onSetPincode) {
@@ -119,9 +121,9 @@ export default function SetResourcePincodeForm({
>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>Set Pincode</CredenzaTitle>
<CredenzaTitle>{t('resourcePincodeSetupTitle')}</CredenzaTitle>
<CredenzaDescription>
Set a pincode to protect this resource
{t('resourcePincodeSetupTitleDescription')}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
@@ -136,7 +138,7 @@ export default function SetResourcePincodeForm({
name="pincode"
render={({ field }) => (
<FormItem>
<FormLabel>PIN Code</FormLabel>
<FormLabel>{t('resourcePincode')}</FormLabel>
<FormControl>
<div className="flex justify-center">
<InputOTP
@@ -182,7 +184,7 @@ export default function SetResourcePincodeForm({
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
<Button variant="outline">{t('close')}</Button>
</CredenzaClose>
<Button
type="submit"
@@ -190,7 +192,7 @@ export default function SetResourcePincodeForm({
loading={loading}
disabled={loading}
>
Enable PIN Code Protection
{t('resourcePincodeSubmit')}
</Button>
</CredenzaFooter>
</CredenzaContent>

View File

@@ -48,6 +48,7 @@ import { useRouter } from "next/navigation";
import { UserType } from "@server/types/UserTypes";
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { InfoIcon } from "lucide-react";
import { useTranslations } from "next-intl";
const UsersRolesFormSchema = z.object({
roles: z.array(
@@ -82,6 +83,7 @@ export default function ResourceAuthenticationPage() {
const api = createApiClient({ env });
const router = useRouter();
const t = useTranslations();
const [pageLoading, setPageLoading] = useState(true);
@@ -203,10 +205,10 @@ export default function ResourceAuthenticationPage() {
console.error(e);
toast({
variant: "destructive",
title: "Failed to fetch data",
title: t('resourceErrorAuthFetch'),
description: formatAxiosError(
e,
"An error occurred while fetching the data"
t('resourceErrorAuthFetchDescription')
)
});
}
@@ -233,18 +235,18 @@ export default function ResourceAuthenticationPage() {
});
toast({
title: "Saved successfully",
description: "Whitelist settings have been saved"
title: t('resourceWhitelistSave'),
description: t('resourceWhitelistSaveDescription')
});
router.refresh();
} catch (e) {
console.error(e);
toast({
variant: "destructive",
title: "Failed to save whitelist",
title: t('resourceErrorWhitelistSave'),
description: formatAxiosError(
e,
"An error occurred while saving the whitelist"
t('resourceErrorWhitelistSaveDescription')
)
});
} finally {
@@ -281,18 +283,18 @@ export default function ResourceAuthenticationPage() {
});
toast({
title: "Saved successfully",
description: "Authentication settings have been saved"
title: t('resourceAuthSettingsSave'),
description: t('resourceAuthSettingsSaveDescription')
});
router.refresh();
} catch (e) {
console.error(e);
toast({
variant: "destructive",
title: "Failed to set roles",
title: t('resourceErrorUsersRolesSave'),
description: formatAxiosError(
e,
"An error occurred while setting the roles"
t('resourceErrorUsersRolesSaveDescription')
)
});
} finally {
@@ -308,9 +310,8 @@ export default function ResourceAuthenticationPage() {
})
.then(() => {
toast({
title: "Resource password removed",
description:
"The resource password has been removed successfully"
title: t('resourcePasswordRemove'),
description: t('resourcePasswordRemoveDescription')
});
updateAuthInfo({
@@ -321,10 +322,10 @@ export default function ResourceAuthenticationPage() {
.catch((e) => {
toast({
variant: "destructive",
title: "Error removing resource password",
title: t('resourceErrorPasswordRemove'),
description: formatAxiosError(
e,
"An error occurred while removing the resource password"
t('resourceErrorPasswordRemoveDescription')
)
});
})
@@ -339,9 +340,8 @@ export default function ResourceAuthenticationPage() {
})
.then(() => {
toast({
title: "Resource pincode removed",
description:
"The resource password has been removed successfully"
title: t('resourcePincodeRemove'),
description: t('resourcePincodeRemoveDescription')
});
updateAuthInfo({
@@ -352,10 +352,10 @@ export default function ResourceAuthenticationPage() {
.catch((e) => {
toast({
variant: "destructive",
title: "Error removing resource pincode",
title: t('resourceErrorPincodeRemove'),
description: formatAxiosError(
e,
"An error occurred while removing the resource pincode"
t('resourceErrorPincodeRemoveDescription')
)
});
})
@@ -400,18 +400,17 @@ export default function ResourceAuthenticationPage() {
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
Users & Roles
{t('resourceUsersRoles')}
</SettingsSectionTitle>
<SettingsSectionDescription>
Configure which users and roles can visit this
resource
{t('resourceUsersRolesDescription')}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SwitchInput
id="sso-toggle"
label="Use Platform SSO"
description="Existing users will only have to log in once for all resources that have this enabled."
label={t('ssoUse')}
description={t('ssoUseDescription')}
defaultChecked={resource.sso}
onCheckedChange={(val) => setSsoEnabled(val)}
/>
@@ -431,7 +430,7 @@ export default function ResourceAuthenticationPage() {
name="roles"
render={({ field }) => (
<FormItem className="flex flex-col items-start">
<FormLabel>Roles</FormLabel>
<FormLabel>{t('roles')}</FormLabel>
<FormControl>
<TagInput
{...field}
@@ -441,7 +440,7 @@ export default function ResourceAuthenticationPage() {
setActiveTagIndex={
setActiveRolesTagIndex
}
placeholder="Select a role"
placeholder={t('accessRoleSelect2')}
size="sm"
tags={
usersRolesForm.getValues()
@@ -475,8 +474,7 @@ export default function ResourceAuthenticationPage() {
</FormControl>
<FormMessage />
<FormDescription>
Admins can always access
this resource.
{t('resourceRoleDescription')}
</FormDescription>
</FormItem>
)}
@@ -486,7 +484,7 @@ export default function ResourceAuthenticationPage() {
name="users"
render={({ field }) => (
<FormItem className="flex flex-col items-start">
<FormLabel>Users</FormLabel>
<FormLabel>{t('users')}</FormLabel>
<FormControl>
<TagInput
{...field}
@@ -496,7 +494,7 @@ export default function ResourceAuthenticationPage() {
setActiveTagIndex={
setActiveUsersTagIndex
}
placeholder="Select a user"
placeholder={t('accessUserSelect')}
tags={
usersRolesForm.getValues()
.users
@@ -544,7 +542,7 @@ export default function ResourceAuthenticationPage() {
disabled={loadingSaveUsersRoles}
form="users-roles-form"
>
Save Users & Roles
{t('resourceUsersRolesSubmit')}
</Button>
</SettingsSectionFooter>
</SettingsSection>
@@ -552,11 +550,10 @@ export default function ResourceAuthenticationPage() {
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
Authentication Methods
{t('resourceAuthMethods')}
</SettingsSectionTitle>
<SettingsSectionDescription>
Allow access to the resource via additional auth
methods
{t('resourceAuthMethodsDescriptions')}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
@@ -567,8 +564,7 @@ export default function ResourceAuthenticationPage() {
>
<Key />
<span>
Password Protection{" "}
{authInfo.password ? "Enabled" : "Disabled"}
{t('resourcePasswordProtection', {status: authInfo.password? t('enabled') : t('disabled')})}
</span>
</div>
<Button
@@ -581,8 +577,8 @@ export default function ResourceAuthenticationPage() {
loading={loadingRemoveResourcePassword}
>
{authInfo.password
? "Remove Password"
: "Add Password"}
? t('passwordRemove')
: t('passwordAdd')}
</Button>
</div>
@@ -593,8 +589,7 @@ export default function ResourceAuthenticationPage() {
>
<Binary />
<span>
PIN Code Protection{" "}
{authInfo.pincode ? "Enabled" : "Disabled"}
{t('resourcePincodeProtection', {status: authInfo.pincode ? t('enabled') : t('disabled')})}
</span>
</div>
<Button
@@ -607,8 +602,8 @@ export default function ResourceAuthenticationPage() {
loading={loadingRemoveResourcePincode}
>
{authInfo.pincode
? "Remove PIN Code"
: "Add PIN Code"}
? t('pincodeRemove')
: t('pincodeAdd')}
</Button>
</div>
</SettingsSectionBody>
@@ -617,11 +612,10 @@ export default function ResourceAuthenticationPage() {
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
One-time Passwords
{t('otpEmailTitle')}
</SettingsSectionTitle>
<SettingsSectionDescription>
Require email-based authentication for resource
access
{t('otpEmailTitleDescription')}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
@@ -629,16 +623,16 @@ export default function ResourceAuthenticationPage() {
<Alert variant="neutral" className="mb-4">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
SMTP Required
{t('otpEmailSmtpRequired')}
</AlertTitle>
<AlertDescription>
SMTP must be enabled on the server to use one-time password authentication.
{t('otpEmailSmtpRequiredDescription')}
</AlertDescription>
</Alert>
)}
<SwitchInput
id="whitelist-toggle"
label="Email Whitelist"
label={t('otpEmailWhitelist')}
defaultChecked={resource.emailWhitelistEnabled}
onCheckedChange={setWhitelistEnabled}
disabled={!env.email.emailEnabled}
@@ -654,8 +648,8 @@ export default function ResourceAuthenticationPage() {
<FormItem>
<FormLabel>
<InfoPopup
text="Whitelisted Emails"
info="Only users with these email addresses will be able to access this resource. They will be prompted to enter a one-time password sent to their email. Wildcards (*@example.com) can be used to allow any email address from a domain."
text={t('otpEmailWhitelistList')}
info={t('otpEmailWhitelistListDescription')}
/>
</FormLabel>
<FormControl>
@@ -678,8 +672,7 @@ export default function ResourceAuthenticationPage() {
.regex(
/^\*@[\w.-]+\.[a-zA-Z]{2,}$/,
{
message:
"Invalid email address. Wildcard (*) must be the entire local part."
message: t('otpEmailErrorInvalid')
}
)
)
@@ -690,7 +683,7 @@ export default function ResourceAuthenticationPage() {
setActiveTagIndex={
setActiveEmailTagIndex
}
placeholder="Enter an email"
placeholder={t('otpEmailEnter')}
tags={
whitelistForm.getValues()
.emails
@@ -713,9 +706,7 @@ export default function ResourceAuthenticationPage() {
/>
</FormControl>
<FormDescription>
Press enter to add an
email after typing it in
the input field.
{t('otpEmailEnterDescription')}
</FormDescription>
</FormItem>
)}
@@ -731,7 +722,7 @@ export default function ResourceAuthenticationPage() {
loading={loadingSaveWhitelist}
disabled={loadingSaveWhitelist}
>
Save Whitelist
{t('otpEmailWhitelistSave')}
</Button>
</SettingsSectionFooter>
</SettingsSection>

View File

@@ -65,51 +65,12 @@ import {
updateResourceRule
} from "@server/routers/resource";
import { SwitchInput } from "@app/components/SwitchInput";
const GeneralFormSchema = z
.object({
subdomain: z.string().optional(),
name: z.string().min(1).max(255),
proxyPort: z.number().optional(),
http: z.boolean(),
isBaseDomain: z.boolean().optional(),
domainId: z.string().optional()
})
.refine(
(data) => {
if (!data.http) {
return z
.number()
.int()
.min(1)
.max(65535)
.safeParse(data.proxyPort).success;
}
return true;
},
{
message: "Invalid port number",
path: ["proxyPort"]
}
)
.refine(
(data) => {
if (data.http && !data.isBaseDomain) {
return subdomainSchema.safeParse(data.subdomain).success;
}
return true;
},
{
message: "Invalid subdomain",
path: ["subdomain"]
}
);
import { useTranslations } from "next-intl";
const TransferFormSchema = z.object({
siteId: z.number()
});
type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
type TransferFormValues = z.infer<typeof TransferFormSchema>;
export default function GeneralForm() {
@@ -118,6 +79,7 @@ export default function GeneralForm() {
const { resource, updateResource } = useResourceContext();
const { org } = useOrgContext();
const router = useRouter();
const t = useTranslations();
const { env } = useEnvContext();
@@ -138,6 +100,47 @@ export default function GeneralForm() {
resource.isBaseDomain ? "basedomain" : "subdomain"
);
const GeneralFormSchema = z
.object({
subdomain: z.string().optional(),
name: z.string().min(1).max(255),
proxyPort: z.number().optional(),
http: z.boolean(),
isBaseDomain: z.boolean().optional(),
domainId: z.string().optional()
})
.refine(
(data) => {
if (!data.http) {
return z
.number()
.int()
.min(1)
.max(65535)
.safeParse(data.proxyPort).success;
}
return true;
},
{
message: t("proxyErrorInvalidPort"),
path: ["proxyPort"]
}
)
.refine(
(data) => {
if (data.http && !data.isBaseDomain) {
return subdomainSchema.safeParse(data.subdomain).success;
}
return true;
},
{
message: t("subdomainErrorInvalid"),
path: ["subdomain"]
}
);
type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
const form = useForm<GeneralFormValues>({
resolver: zodResolver(GeneralFormSchema),
defaultValues: {
@@ -174,10 +177,10 @@ export default function GeneralForm() {
.catch((e) => {
toast({
variant: "destructive",
title: "Error fetching domains",
title: t("domainErrorFetch"),
description: formatAxiosError(
e,
"An error occurred when fetching the domains"
t("domainErrorFetchDescription")
)
});
});
@@ -216,18 +219,18 @@ export default function GeneralForm() {
.catch((e) => {
toast({
variant: "destructive",
title: "Failed to update resource",
title: t("resourceErrorUpdate"),
description: formatAxiosError(
e,
"An error occurred while updating the resource"
t("resourceErrorUpdateDescription")
)
});
});
if (res && res.status === 200) {
toast({
title: "Resource updated",
description: "The resource has been updated successfully"
title: t("resourceUpdated"),
description: t("resourceUpdatedDescription")
});
const resource = res.data.data;
@@ -255,18 +258,18 @@ export default function GeneralForm() {
.catch((e) => {
toast({
variant: "destructive",
title: "Failed to transfer resource",
title: t("resourceErrorTransfer"),
description: formatAxiosError(
e,
"An error occurred while transferring the resource"
t("resourceErrorTransferDescription")
)
});
});
if (res && res.status === 200) {
toast({
title: "Resource transferred",
description: "The resource has been transferred successfully"
title: t("resourceTransferred"),
description: t("resourceTransferredDescription")
});
router.refresh();
@@ -290,10 +293,10 @@ export default function GeneralForm() {
.catch((e) => {
toast({
variant: "destructive",
title: "Failed to toggle resource",
title: t("resourceErrorToggle"),
description: formatAxiosError(
e,
"An error occurred while updating the resource"
t("resourceErrorToggleDescription")
)
});
});
@@ -308,15 +311,17 @@ export default function GeneralForm() {
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>Visibility</SettingsSectionTitle>
<SettingsSectionTitle>
{t("resourceVisibilityTitle")}
</SettingsSectionTitle>
<SettingsSectionDescription>
Completely enable or disable resource visibility
{t("resourceVisibilityTitleDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SwitchInput
id="enable-resource"
label="Enable Resource"
label={t("resourceEnable")}
defaultChecked={resource.enabled}
onCheckedChange={async (val) => {
await toggleResourceEnabled(val);
@@ -328,10 +333,10 @@ export default function GeneralForm() {
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
General Settings
{t("resourceGeneral")}
</SettingsSectionTitle>
<SettingsSectionDescription>
Configure the general settings for this resource
{t("resourceGeneralDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
@@ -348,7 +353,9 @@ export default function GeneralForm() {
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormLabel>
{t("name")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
@@ -367,7 +374,9 @@ export default function GeneralForm() {
render={({ field }) => (
<FormItem>
<FormLabel>
Domain Type
{t(
"domainType"
)}
</FormLabel>
<Select
value={
@@ -398,11 +407,14 @@ export default function GeneralForm() {
</FormControl>
<SelectContent>
<SelectItem value="subdomain">
Subdomain
{t(
"subdomain"
)}
</SelectItem>
<SelectItem value="basedomain">
Base
Domain
{t(
"baseDomain"
)}
</SelectItem>
</SelectContent>
</Select>
@@ -416,7 +428,7 @@ export default function GeneralForm() {
{domainType === "subdomain" ? (
<div className="w-fill space-y-2">
<FormLabel>
Subdomain
{t("subdomain")}
</FormLabel>
<div className="flex">
<div className="w-1/2">
@@ -502,7 +514,9 @@ export default function GeneralForm() {
render={({ field }) => (
<FormItem>
<FormLabel>
Base Domain
{t(
"baseDomain"
)}
</FormLabel>
<Select
onValueChange={
@@ -556,7 +570,9 @@ export default function GeneralForm() {
render={({ field }) => (
<FormItem>
<FormLabel>
Port Number
{t(
"resourcePortNumber"
)}
</FormLabel>
<FormControl>
<Input
@@ -596,7 +612,7 @@ export default function GeneralForm() {
disabled={saveLoading}
form="general-settings-form"
>
Save General Settings
{t("saveGeneralSettings")}
</Button>
</SettingsSectionFooter>
</SettingsSection>
@@ -604,10 +620,10 @@ export default function GeneralForm() {
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
Transfer Resource
{t("resourceTransfer")}
</SettingsSectionTitle>
<SettingsSectionDescription>
Transfer this resource to a different site
{t("resourceTransferDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
@@ -627,7 +643,7 @@ export default function GeneralForm() {
render={({ field }) => (
<FormItem>
<FormLabel>
Destination Site
{t("siteDestination")}
</FormLabel>
<Popover
open={open}
@@ -652,16 +668,24 @@ export default function GeneralForm() {
site.siteId ===
field.value
)?.name
: "Select site"}
: t(
"siteSelect"
)}
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-full p-0">
<Command>
<CommandInput placeholder="Search sites" />
<CommandInput
placeholder={t(
"searchSites"
)}
/>
<CommandEmpty>
No sites found.
{t(
"sitesNotFound"
)}
</CommandEmpty>
<CommandGroup>
{sites.map(
@@ -716,7 +740,7 @@ export default function GeneralForm() {
disabled={transferLoading}
form="transfer-form"
>
Transfer Resource
{t("resourceTransferSubmit")}
</Button>
</SettingsSectionFooter>
</SettingsSection>

View File

@@ -14,6 +14,7 @@ import OrgProvider from "@app/providers/OrgProvider";
import { cache } from "react";
import ResourceInfoBox from "./ResourceInfoBox";
import { GetSiteResponse } from "@server/routers/site";
import { getTranslations } from 'next-intl/server';
interface ResourceLayoutProps {
children: React.ReactNode;
@@ -22,6 +23,7 @@ interface ResourceLayoutProps {
export default async function ResourceLayout(props: ResourceLayoutProps) {
const params = await props.params;
const t = await getTranslations();
const { children } = props;
@@ -88,22 +90,22 @@ export default async function ResourceLayout(props: ResourceLayoutProps) {
const navItems = [
{
title: "General",
title: t('general'),
href: `/{orgId}/settings/resources/{resourceId}/general`
},
{
title: "Proxy",
title: t('proxy'),
href: `/{orgId}/settings/resources/{resourceId}/proxy`
}
];
if (resource.http) {
navItems.push({
title: "Authentication",
title: t('authentication'),
href: `/{orgId}/settings/resources/{resourceId}/authentication`
});
navItems.push({
title: "Rules",
title: t('rules'),
href: `/{orgId}/settings/resources/{resourceId}/rules`
});
}
@@ -111,8 +113,8 @@ export default async function ResourceLayout(props: ResourceLayoutProps) {
return (
<>
<SettingsSectionTitle
title={`${resource?.name} Settings`}
description="Configure the settings on your resource"
title={t('resourceSetting', {resourceName: resource?.name})}
description={t('resourceSettingDescription')}
/>
<OrgProvider org={org}>

View File

@@ -74,7 +74,7 @@ import {
CollapsibleTrigger
} from "@app/components/ui/collapsible";
import { ContainersSelector } from "@app/components/ContainersSelector";
import { FaDocker } from "react-icons/fa";
import { useTranslations } from "next-intl";
const addTargetSchema = z.object({
ip: z.string().refine(isTargetValid),
@@ -94,51 +94,11 @@ type LocalTarget = Omit<
"protocol"
>;
const proxySettingsSchema = z.object({
setHostHeader: z
.string()
.optional()
.refine(
(data) => {
if (data) {
return tlsNameSchema.safeParse(data).success;
}
return true;
},
{
message:
"Invalid custom Host Header value. Use domain name format, or save empty to unset custom Host Header."
}
)
});
const tlsSettingsSchema = z.object({
ssl: z.boolean(),
tlsServerName: z
.string()
.optional()
.refine(
(data) => {
if (data) {
return tlsNameSchema.safeParse(data).success;
}
return true;
},
{
message:
"Invalid TLS Server Name. Use domain name format, or save empty to remove the TLS Server Name."
}
)
});
type ProxySettingsValues = z.infer<typeof proxySettingsSchema>;
type TlsSettingsValues = z.infer<typeof tlsSettingsSchema>;
type TargetsSettingsValues = z.infer<typeof targetsSettingsSchema>;
export default function ReverseProxyTargets(props: {
params: Promise<{ resourceId: number }>;
}) {
const params = use(props.params);
const t = useTranslations();
const { resource, updateResource } = useResourceContext();
@@ -156,6 +116,45 @@ export default function ReverseProxyTargets(props: {
const [isAdvancedOpen, setIsAdvancedOpen] = useState(false);
const router = useRouter();
const proxySettingsSchema = z.object({
setHostHeader: z
.string()
.optional()
.refine(
(data) => {
if (data) {
return tlsNameSchema.safeParse(data).success;
}
return true;
},
{
message: t('proxyErrorInvalidHeader')
}
)
});
const tlsSettingsSchema = z.object({
ssl: z.boolean(),
tlsServerName: z
.string()
.optional()
.refine(
(data) => {
if (data) {
return tlsNameSchema.safeParse(data).success;
}
return true;
},
{
message: t('proxyErrorTls')
}
)
});
type ProxySettingsValues = z.infer<typeof proxySettingsSchema>;
type TlsSettingsValues = z.infer<typeof tlsSettingsSchema>;
type TargetsSettingsValues = z.infer<typeof targetsSettingsSchema>;
const addTargetForm = useForm({
resolver: zodResolver(addTargetSchema),
defaultValues: {
@@ -204,10 +203,10 @@ export default function ReverseProxyTargets(props: {
console.error(err);
toast({
variant: "destructive",
title: "Failed to fetch targets",
title: t('targetErrorFetch'),
description: formatAxiosError(
err,
"An error occurred while fetching targets"
t('targetErrorFetchDescription')
)
});
} finally {
@@ -229,10 +228,10 @@ export default function ReverseProxyTargets(props: {
console.error(err);
toast({
variant: "destructive",
title: "Failed to fetch resource",
title: t('siteErrorFetch'),
description: formatAxiosError(
err,
"An error occurred while fetching resource"
t('siteErrorFetchDescription')
)
});
}
@@ -252,8 +251,8 @@ export default function ReverseProxyTargets(props: {
if (isDuplicate) {
toast({
variant: "destructive",
title: "Duplicate target",
description: "A target with these settings already exists"
title: t('targetErrorDuplicate'),
description: t('targetErrorDuplicateDescription')
});
return;
}
@@ -265,8 +264,8 @@ export default function ReverseProxyTargets(props: {
if (!isIPInSubnet(targetIp, subnet)) {
toast({
variant: "destructive",
title: "Invalid target IP",
description: "Target IP must be within the site subnet"
title: t('targetWireGuardErrorInvalidIp'),
description: t('targetWireGuardErrorInvalidIpDescription')
});
return;
}
@@ -344,8 +343,8 @@ export default function ReverseProxyTargets(props: {
updateResource({ stickySession: stickySessionData.stickySession });
toast({
title: "Targets updated",
description: "Targets and settings updated successfully"
title: t('targetsUpdated'),
description: t('targetsUpdatedDescription')
});
setTargetsToRemove([]);
@@ -354,10 +353,10 @@ export default function ReverseProxyTargets(props: {
console.error(err);
toast({
variant: "destructive",
title: "Failed to update targets",
title: t('targetsErrorUpdate'),
description: formatAxiosError(
err,
"An error occurred while updating targets"
t('targetsErrorUpdateDescription')
)
});
} finally {
@@ -378,17 +377,17 @@ export default function ReverseProxyTargets(props: {
tlsServerName: data.tlsServerName || null
});
toast({
title: "TLS settings updated",
description: "Your TLS settings have been updated successfully"
title: t('targetTlsUpdate'),
description: t('targetTlsUpdateDescription')
});
} catch (err) {
console.error(err);
toast({
variant: "destructive",
title: "Failed to update TLS settings",
title: t('targetErrorTlsUpdate'),
description: formatAxiosError(
err,
"An error occurred while updating TLS settings"
t('targetErrorTlsUpdateDescription')
)
});
} finally {
@@ -407,18 +406,17 @@ export default function ReverseProxyTargets(props: {
setHostHeader: data.setHostHeader || null
});
toast({
title: "Proxy settings updated",
description:
"Your proxy settings have been updated successfully"
title: t('proxyUpdated'),
description: t('proxyUpdatedDescription')
});
} catch (err) {
console.error(err);
toast({
variant: "destructive",
title: "Failed to update proxy settings",
title: t('proxyErrorUpdate'),
description: formatAxiosError(
err,
"An error occurred while updating proxy settings"
t('proxyErrorUpdateDescription')
)
});
} finally {
@@ -429,7 +427,7 @@ export default function ReverseProxyTargets(props: {
const columns: ColumnDef<LocalTarget>[] = [
{
accessorKey: "ip",
header: "IP / Hostname",
header: t('targetAddr'),
cell: ({ row }) => (
<Input
defaultValue={row.original.ip}
@@ -444,7 +442,7 @@ export default function ReverseProxyTargets(props: {
},
{
accessorKey: "port",
header: "Port",
header: t('targetPort'),
cell: ({ row }) => (
<Input
type="number"
@@ -460,7 +458,7 @@ export default function ReverseProxyTargets(props: {
},
// {
// accessorKey: "protocol",
// header: "Protocol",
// header: t('targetProtocol'),
// cell: ({ row }) => (
// <Select
// defaultValue={row.original.protocol!}
@@ -478,7 +476,7 @@ export default function ReverseProxyTargets(props: {
// },
{
accessorKey: "enabled",
header: "Enabled",
header: t('enabled'),
cell: ({ row }) => (
<Switch
defaultChecked={row.original.enabled}
@@ -505,7 +503,7 @@ export default function ReverseProxyTargets(props: {
variant="outline"
onClick={() => removeTarget(row.original.targetId)}
>
Delete
{t('delete')}
</Button>
</div>
</>
@@ -516,7 +514,7 @@ export default function ReverseProxyTargets(props: {
if (resource.http) {
const methodCol: ColumnDef<LocalTarget> = {
accessorKey: "method",
header: "Method",
header: t('method'),
cell: ({ row }) => (
<Select
defaultValue={row.original.method ?? ""}
@@ -564,10 +562,10 @@ export default function ReverseProxyTargets(props: {
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
Targets Configuration
{t('targets')}
</SettingsSectionTitle>
<SettingsSectionDescription>
Set up targets to route traffic to your services
{t('targetsDescription')}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
@@ -589,8 +587,8 @@ export default function ReverseProxyTargets(props: {
<FormControl>
<SwitchInput
id="sticky-toggle"
label="Enable Sticky Sessions"
description="Keep connections on the same backend target for their entire session."
label={t('targetStickySessions')}
description={t('targetStickySessionsDescription')}
defaultChecked={
field.value
}
@@ -621,7 +619,7 @@ export default function ReverseProxyTargets(props: {
name="method"
render={({ field }) => (
<FormItem>
<FormLabel>Method</FormLabel>
<FormLabel>{t('method')}</FormLabel>
<FormControl>
<Select
value={
@@ -638,7 +636,7 @@ export default function ReverseProxyTargets(props: {
}}
>
<SelectTrigger id="method">
<SelectValue placeholder="Select method" />
<SelectValue placeholder={t('methodSelect')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="http">
@@ -664,7 +662,7 @@ export default function ReverseProxyTargets(props: {
name="ip"
render={({ field }) => (
<FormItem className="relative">
<FormLabel>IP / Hostname</FormLabel>
<FormLabel>{t('targetAddr')}</FormLabel>
<FormControl>
<Input id="ip" {...field} />
</FormControl>
@@ -697,7 +695,7 @@ export default function ReverseProxyTargets(props: {
name="port"
render={({ field }) => (
<FormItem>
<FormLabel>Port</FormLabel>
<FormLabel>{t('targetPort')}</FormLabel>
<FormControl>
<Input
id="port"
@@ -716,7 +714,7 @@ export default function ReverseProxyTargets(props: {
className="mt-6"
disabled={!(watchedIp && watchedPort)}
>
Add Target
{t('targetSubmit')}
</Button>
</div>
</form>
@@ -760,14 +758,13 @@ export default function ReverseProxyTargets(props: {
colSpan={columns.length}
className="h-24 text-center"
>
No targets. Add a target using the form.
{t('targetNoOne')}
</TableCell>
</TableRow>
)}
</TableBody>
<TableCaption>
Adding more than one target above will enable load
balancing.
{t('targetNoOneDescription')}
</TableCaption>
</Table>
</SettingsSectionBody>
@@ -778,7 +775,7 @@ export default function ReverseProxyTargets(props: {
disabled={targetsLoading}
form="targets-settings-form"
>
Save Targets
{t('targetsSubmit')}
</Button>
</SettingsSectionFooter>
</SettingsSection>
@@ -788,10 +785,10 @@ export default function ReverseProxyTargets(props: {
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
Secure Connection Configuration
{t('targetTlsSettings')}
</SettingsSectionTitle>
<SettingsSectionDescription>
Configure SSL/TLS settings for your resource
{t('targetTlsSettingsDescription')}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
@@ -812,7 +809,7 @@ export default function ReverseProxyTargets(props: {
<FormControl>
<SwitchInput
id="ssl-toggle"
label="Enable SSL (https)"
label={t('proxyEnableSSL')}
defaultChecked={
field.value
}
@@ -841,8 +838,7 @@ export default function ReverseProxyTargets(props: {
className="p-0 flex items-center justify-start gap-2 w-full"
>
<p className="text-sm text-muted-foreground">
Advanced TLS
Settings
{t('targetTlsSettingsAdvanced')}
</p>
<div>
<ChevronsUpDown className="h-4 w-4" />
@@ -862,8 +858,7 @@ export default function ReverseProxyTargets(props: {
render={({ field }) => (
<FormItem>
<FormLabel>
TLS Server Name
(SNI)
{t('targetTlsSni')}
</FormLabel>
<FormControl>
<Input
@@ -871,11 +866,7 @@ export default function ReverseProxyTargets(props: {
/>
</FormControl>
<FormDescription>
The TLS Server
Name to use for
SNI. Leave empty
to use the
default.
{t('targetTlsSniDescription')}
</FormDescription>
<FormMessage />
</FormItem>
@@ -893,18 +884,17 @@ export default function ReverseProxyTargets(props: {
loading={httpsTlsLoading}
form="tls-settings-form"
>
Save Settings
{t('targetTlsSubmit')}
</Button>
</SettingsSectionFooter>
</SettingsSection>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
Additional Proxy Settings
{t('proxyAdditional')}
</SettingsSectionTitle>
<SettingsSectionDescription>
Configure how your resource handles proxy
settings
{t('proxyAdditionalDescription')}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
@@ -923,16 +913,13 @@ export default function ReverseProxyTargets(props: {
render={({ field }) => (
<FormItem>
<FormLabel>
Custom Host Header
{t('proxyCustomHeader')}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
The host header to set
when proxying requests.
Leave empty to use the
default.
{t('proxyCustomHeaderDescription')}
</FormDescription>
<FormMessage />
</FormItem>
@@ -948,7 +935,7 @@ export default function ReverseProxyTargets(props: {
loading={proxySettingsLoading}
form="proxy-settings-form"
>
Save Settings
{t('targetTlsSubmit')}
</Button>
</SettingsSectionFooter>
</SettingsSection>
@@ -963,8 +950,10 @@ function isIPInSubnet(subnet: string, ip: string): boolean {
const [subnetIP, maskBits] = subnet.split("/");
const mask = parseInt(maskBits);
const t = useTranslations();
if (mask < 0 || mask > 32) {
throw new Error("Invalid subnet mask. Must be between 0 and 32.");
throw new Error(t('subnetMaskErrorInvalid'));
}
// Convert IP addresses to binary numbers
@@ -981,15 +970,17 @@ function isIPInSubnet(subnet: string, ip: string): boolean {
function ipToNumber(ip: string): number {
// Validate IP address format
const parts = ip.split(".");
const t = useTranslations();
if (parts.length !== 4) {
throw new Error("Invalid IP address format");
throw new Error(t('ipAddressErrorInvalidFormat'));
}
// Convert IP octets to 32-bit number
return parts.reduce((num, octet) => {
const oct = parseInt(octet);
if (isNaN(oct) || oct < 0 || oct > 255) {
throw new Error("Invalid IP address octet");
throw new Error(t('ipAddressErrorInvalidOctet'));
}
return (num << 8) + oct;
}, 0);

View File

@@ -1,4 +1,5 @@
"use client";
import { useEffect, useState, use } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -72,6 +73,7 @@ import {
} from "@server/lib/validators";
import { Switch } from "@app/components/ui/switch";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
// Schema for rule validation
const addRuleSchema = z.object({
@@ -86,17 +88,6 @@ type LocalRule = ArrayElement<ListResourceRulesResponse["rules"]> & {
updated?: boolean;
};
enum RuleAction {
ACCEPT = "Always Allow",
DROP = "Always Deny"
}
enum RuleMatch {
PATH = "Path",
IP = "IP",
CIDR = "IP Range"
}
export default function ResourceRules(props: {
params: Promise<{ resourceId: number }>;
}) {
@@ -109,6 +100,19 @@ export default function ResourceRules(props: {
const [pageLoading, setPageLoading] = useState(true);
const [rulesEnabled, setRulesEnabled] = useState(resource.applyRules);
const router = useRouter();
const t = useTranslations();
const RuleAction = {
ACCEPT: t('alwaysAllow'),
DROP: t('alwaysDeny')
} as const;
const RuleMatch = {
PATH: t('path'),
IP: "IP",
CIDR: t('ipAddressRange')
} as const;
const addRuleForm = useForm({
resolver: zodResolver(addRuleSchema),
@@ -132,10 +136,10 @@ export default function ResourceRules(props: {
console.error(err);
toast({
variant: "destructive",
title: "Failed to fetch rules",
title: t('rulesErrorFetch'),
description: formatAxiosError(
err,
"An error occurred while fetching rules"
t('rulesErrorFetchDescription')
)
});
} finally {
@@ -156,8 +160,8 @@ export default function ResourceRules(props: {
if (isDuplicate) {
toast({
variant: "destructive",
title: "Duplicate rule",
description: "A rule with these settings already exists"
title: t('rulesErrorDuplicate'),
description: t('rulesErrorDuplicateDescription')
});
return;
}
@@ -165,8 +169,8 @@ export default function ResourceRules(props: {
if (data.match === "CIDR" && !isValidCIDR(data.value)) {
toast({
variant: "destructive",
title: "Invalid CIDR",
description: "Please enter a valid CIDR value"
title: t('rulesErrorInvalidIpAddressRange'),
description: t('rulesErrorInvalidIpAddressRangeDescription')
});
setLoading(false);
return;
@@ -174,8 +178,8 @@ export default function ResourceRules(props: {
if (data.match === "PATH" && !isValidUrlGlobPattern(data.value)) {
toast({
variant: "destructive",
title: "Invalid URL path",
description: "Please enter a valid URL path value"
title: t('rulesErrorInvalidUrl'),
description: t('rulesErrorInvalidUrlDescription')
});
setLoading(false);
return;
@@ -183,8 +187,8 @@ export default function ResourceRules(props: {
if (data.match === "IP" && !isValidIP(data.value)) {
toast({
variant: "destructive",
title: "Invalid IP",
description: "Please enter a valid IP address"
title: t('rulesErrorInvalidIpAddress'),
description: t('rulesErrorInvalidIpAddressDescription')
});
setLoading(false);
return;
@@ -239,10 +243,10 @@ export default function ResourceRules(props: {
console.error(err);
toast({
variant: "destructive",
title: "Failed to update rules",
title: t('rulesErrorUpdate'),
description: formatAxiosError(
err,
"An error occurred while updating rules"
t('rulesErrorUpdateDescription')
)
});
});
@@ -252,8 +256,8 @@ export default function ResourceRules(props: {
updateResource({ applyRules: val });
toast({
title: "Enable Rules",
description: "Rule evaluation has been updated"
title: t('rulesUpdated'),
description: t('rulesUpdatedDescription')
});
router.refresh();
}
@@ -262,11 +266,11 @@ export default function ResourceRules(props: {
function getValueHelpText(type: string) {
switch (type) {
case "CIDR":
return "Enter an address in CIDR format (e.g., 103.21.244.0/22)";
return t('rulesMatchIpAddressRangeDescription');
case "IP":
return "Enter an IP address (e.g., 103.21.244.12)";
return t('rulesMatchIpAddress');
case "PATH":
return "Enter a URL path or pattern (e.g., /api/v1/todos or /api/v1/*)";
return t('rulesMatchUrl');
}
}
@@ -285,8 +289,8 @@ export default function ResourceRules(props: {
if (rule.match === "CIDR" && !isValidCIDR(rule.value)) {
toast({
variant: "destructive",
title: "Invalid CIDR",
description: "Please enter a valid CIDR value"
title: t('rulesErrorInvalidIpAddressRange'),
description: t('rulesErrorInvalidIpAddressRangeDescription')
});
setLoading(false);
return;
@@ -297,8 +301,8 @@ export default function ResourceRules(props: {
) {
toast({
variant: "destructive",
title: "Invalid URL path",
description: "Please enter a valid URL path value"
title: t('rulesErrorInvalidUrl'),
description: t('rulesErrorInvalidUrlDescription')
});
setLoading(false);
return;
@@ -306,8 +310,8 @@ export default function ResourceRules(props: {
if (rule.match === "IP" && !isValidIP(rule.value)) {
toast({
variant: "destructive",
title: "Invalid IP",
description: "Please enter a valid IP address"
title: t('rulesErrorInvalidIpAddress'),
description: t('rulesErrorInvalidIpAddressDescription')
});
setLoading(false);
return;
@@ -316,8 +320,8 @@ export default function ResourceRules(props: {
if (rule.priority === undefined) {
toast({
variant: "destructive",
title: "Invalid Priority",
description: "Please enter a valid priority"
title: t('rulesErrorInvalidPriority'),
description: t('rulesErrorInvalidPriorityDescription')
});
setLoading(false);
return;
@@ -328,8 +332,8 @@ export default function ResourceRules(props: {
if (priorities.length !== new Set(priorities).size) {
toast({
variant: "destructive",
title: "Duplicate Priorities",
description: "Please enter unique priorities"
title: t('rulesErrorDuplicatePriority'),
description: t('rulesErrorDuplicatePriorityDescription')
});
setLoading(false);
return;
@@ -368,8 +372,8 @@ export default function ResourceRules(props: {
}
toast({
title: "Rules updated",
description: "Rules updated successfully"
title: t('ruleUpdated'),
description: t('ruleUpdatedDescription')
});
setRulesToRemove([]);
@@ -378,10 +382,10 @@ export default function ResourceRules(props: {
console.error(err);
toast({
variant: "destructive",
title: "Operation failed",
title: t('ruleErrorUpdate'),
description: formatAxiosError(
err,
"An error occurred during the save operation"
t('ruleErrorUpdateDescription')
)
});
}
@@ -399,7 +403,7 @@ export default function ResourceRules(props: {
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Priority
{t('rulesPriority')}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
@@ -419,8 +423,8 @@ export default function ResourceRules(props: {
if (!parsed.data) {
toast({
variant: "destructive",
title: "Invalid IP",
description: "Please enter a valid priority"
title: t('rulesErrorInvalidIpAddress'), // correct priority or IP?
description: t('rulesErrorInvalidPriorityDescription')
});
setLoading(false);
return;
@@ -435,7 +439,7 @@ export default function ResourceRules(props: {
},
{
accessorKey: "action",
header: "Action",
header: t('rulesAction'),
cell: ({ row }) => (
<Select
defaultValue={row.original.action}
@@ -457,7 +461,7 @@ export default function ResourceRules(props: {
},
{
accessorKey: "match",
header: "Match Type",
header: t('rulesMatchType'),
cell: ({ row }) => (
<Select
defaultValue={row.original.match}
@@ -478,7 +482,7 @@ export default function ResourceRules(props: {
},
{
accessorKey: "value",
header: "Value",
header: t('value'),
cell: ({ row }) => (
<Input
defaultValue={row.original.value}
@@ -493,7 +497,7 @@ export default function ResourceRules(props: {
},
{
accessorKey: "enabled",
header: "Enabled",
header: t('enabled'),
cell: ({ row }) => (
<Switch
defaultChecked={row.original.enabled}
@@ -511,7 +515,7 @@ export default function ResourceRules(props: {
variant="outline"
onClick={() => removeRule(row.original.ruleId)}
>
Delete
{t('delete')}
</Button>
</div>
)
@@ -541,46 +545,40 @@ export default function ResourceRules(props: {
<SettingsContainer>
<Alert className="hidden md:block">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">About Rules</AlertTitle>
<AlertTitle className="font-semibold">{t('rulesAbout')}</AlertTitle>
<AlertDescription className="mt-4">
<div className="space-y-1 mb-4">
<p>
Rules allow you to control access to your resource
based on a set of criteria. You can create rules to
allow or deny access based on IP address or URL
path.
{t('rulesAboutDescription')}
</p>
</div>
<InfoSections cols={2}>
<InfoSection>
<InfoSectionTitle>Actions</InfoSectionTitle>
<InfoSectionTitle>{t('rulesActions')}</InfoSectionTitle>
<ul className="text-sm text-muted-foreground space-y-1">
<li className="flex items-center gap-2">
<Check className="text-green-500 w-4 h-4" />
Always Allow: Bypass all authentication
methods
{t('rulesActionAlwaysAllow')}
</li>
<li className="flex items-center gap-2">
<X className="text-red-500 w-4 h-4" />
Always Deny: Block all requests; no
authentication can be attempted
{t('rulesActionAlwaysDeny')}
</li>
</ul>
</InfoSection>
<InfoSection>
<InfoSectionTitle>
Matching Criteria
{t('rulesMatchCriteria')}
</InfoSectionTitle>
<ul className="text-sm text-muted-foreground space-y-1">
<li className="flex items-center gap-2">
Match a specific IP address
{t('rulesMatchCriteriaIpAddress')}
</li>
<li className="flex items-center gap-2">
Match a range of IP addresses in CIDR
notation
{t('rulesMatchCriteriaIpAddressRange')}
</li>
<li className="flex items-center gap-2">
Match a URL path or pattern
{t('rulesMatchCriteriaUrl')}
</li>
</ul>
</InfoSection>
@@ -590,15 +588,15 @@ export default function ResourceRules(props: {
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>Enable Rules</SettingsSectionTitle>
<SettingsSectionTitle>{t('rulesEnable')}</SettingsSectionTitle>
<SettingsSectionDescription>
Enable or disable rule evaluation for this resource
{t('rulesEnableDescription')}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SwitchInput
id="rules-toggle"
label="Enable Rules"
label={t('rulesEnable')}
defaultChecked={rulesEnabled}
onCheckedChange={async (val) => {
await saveApplyRules(val);
@@ -610,10 +608,10 @@ export default function ResourceRules(props: {
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
Resource Rules Configuration
{t('rulesResource')}
</SettingsSectionTitle>
<SettingsSectionDescription>
Configure rules to control access to your resource
{t('rulesResourceDescription')}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
@@ -628,7 +626,7 @@ export default function ResourceRules(props: {
name="action"
render={({ field }) => (
<FormItem>
<FormLabel>Action</FormLabel>
<FormLabel>{t('rulesAction')}</FormLabel>
<FormControl>
<Select
value={field.value}
@@ -658,7 +656,7 @@ export default function ResourceRules(props: {
name="match"
render={({ field }) => (
<FormItem>
<FormLabel>Match Type</FormLabel>
<FormLabel>{t('rulesMatchType')}</FormLabel>
<FormControl>
<Select
value={field.value}
@@ -694,7 +692,7 @@ export default function ResourceRules(props: {
render={({ field }) => (
<FormItem className="space-y-0 mb-2">
<InfoPopup
text="Value"
text={t('value')}
info={
getValueHelpText(
addRuleForm.watch(
@@ -716,7 +714,7 @@ export default function ResourceRules(props: {
className="mb-2"
disabled={!rulesEnabled}
>
Add Rule
{t('ruleSubmit')}
</Button>
</div>
</form>
@@ -759,13 +757,13 @@ export default function ResourceRules(props: {
colSpan={columns.length}
className="h-24 text-center"
>
No rules. Add a rule using the form.
{t('rulesNoOne')}
</TableCell>
</TableRow>
)}
</TableBody>
<TableCaption>
Rules are evaluated by priority in ascending order.
{t('rulesOrder')}
</TableCaption>
</Table>
</SettingsSectionBody>
@@ -775,7 +773,7 @@ export default function ResourceRules(props: {
loading={loading}
disabled={loading}
>
Save Rules
{t('rulesSubmit')}
</Button>
</SettingsSectionFooter>
</SettingsSection>

View File

@@ -62,6 +62,7 @@ import { cn } from "@app/lib/cn";
import { SquareArrowOutUpRight } from "lucide-react";
import CopyTextBox from "@app/components/CopyTextBox";
import Link from "next/link";
import { useTranslations } from "next-intl";
const baseResourceFormSchema = z.object({
name: z.string().min(1).max(255),
@@ -104,6 +105,7 @@ export default function Page() {
const api = createApiClient({ env });
const { orgId } = useParams();
const router = useRouter();
const t = useTranslations();
const [loadingPage, setLoadingPage] = useState(true);
const [sites, setSites] = useState<ListSitesResponse["sites"]>([]);
@@ -117,15 +119,13 @@ export default function Page() {
const resourceTypes: ReadonlyArray<ResourceTypeOption> = [
{
id: "http",
title: "HTTPS Resource",
description:
"Proxy requests to your app over HTTPS using a subdomain or base domain."
title: t('resourceHTTP'),
description: t('resourceHTTPDescription')
},
{
id: "raw",
title: "Raw TCP/UDP Resource",
description:
"Proxy requests to your app over TCP/UDP using a port number.",
title: t('resourceRaw'),
description: t('resourceRawDescription'),
disabled: !env.flags.allowRawResources
}
];
@@ -199,10 +199,10 @@ export default function Page() {
.catch((e) => {
toast({
variant: "destructive",
title: "Error creating resource",
title: t('resourceErrorCreate'),
description: formatAxiosError(
e,
"An error occurred when creating the resource"
t('resourceErrorCreateDescription')
)
});
});
@@ -219,11 +219,11 @@ export default function Page() {
}
}
} catch (e) {
console.error("Error creating resource:", e);
console.error(t('resourceErrorCreateMessage'), e);
toast({
variant: "destructive",
title: "Error creating resource",
description: "An unexpected error occurred"
title: t('resourceErrorCreate'),
description:t('resourceErrorCreateMessageDescription')
});
}
@@ -242,10 +242,10 @@ export default function Page() {
.catch((e) => {
toast({
variant: "destructive",
title: "Error fetching sites",
title: t('sitesErrorFetch'),
description: formatAxiosError(
e,
"An error occurred when fetching the sites"
t('sitesErrorFetchDescription')
)
});
});
@@ -270,10 +270,10 @@ export default function Page() {
.catch((e) => {
toast({
variant: "destructive",
title: "Error fetching domains",
title: t('domainsErrorFetch'),
description: formatAxiosError(
e,
"An error occurred when fetching the domains"
t('domainsErrorFetchDescription')
)
});
});
@@ -300,8 +300,8 @@ export default function Page() {
<>
<div className="flex justify-between">
<HeaderTitle
title="Create Resource"
description="Follow the steps below to create a new resource"
title={t('resourceCreate')}
description={t('resourceCreateDescription')}
/>
<Button
variant="outline"
@@ -309,7 +309,7 @@ export default function Page() {
router.push(`/${orgId}/settings/resources`);
}}
>
See All Resources
{t('resourceSeeAll')}
</Button>
</div>
@@ -320,7 +320,7 @@ export default function Page() {
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
Resource Information
{t('resourceInfo')}
</SettingsSectionTitle>
</SettingsSectionHeader>
<SettingsSectionBody>
@@ -336,7 +336,7 @@ export default function Page() {
render={({ field }) => (
<FormItem>
<FormLabel>
Name
{t('name')}
</FormLabel>
<FormControl>
<Input
@@ -345,9 +345,7 @@ export default function Page() {
</FormControl>
<FormMessage />
<FormDescription>
This is the
display name for
the resource.
{t('resourceNameDescription')}
</FormDescription>
</FormItem>
)}
@@ -359,7 +357,7 @@ export default function Page() {
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>
Site
{t('site')}
</FormLabel>
<Popover>
<PopoverTrigger
@@ -384,19 +382,17 @@ export default function Page() {
field.value
)
?.name
: "Select site"}
: t('siteSelect')}
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="p-0">
<Command>
<CommandInput placeholder="Search site" />
<CommandInput placeholder={t('siteSearch')} />
<CommandList>
<CommandEmpty>
No
site
found.
{t('siteNotFound')}
</CommandEmpty>
<CommandGroup>
{sites.map(
@@ -437,10 +433,7 @@ export default function Page() {
</Popover>
<FormMessage />
<FormDescription>
This site will
provide
connectivity to
the resource.
{t('siteSelectionDescription')}
</FormDescription>
</FormItem>
)}
@@ -454,11 +447,10 @@ export default function Page() {
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
Resource Type
{t('resourceType')}
</SettingsSectionTitle>
<SettingsSectionDescription>
Determine how you want to access your
resource
{t('resourceTypeDescription')}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
@@ -480,11 +472,10 @@ export default function Page() {
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
HTTPS Settings
{t('resourceHTTPSSettings')}
</SettingsSectionTitle>
<SettingsSectionDescription>
Configure how your resource will be
accessed over HTTPS
{t('resourceHTTPSSettingsDescription')}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
@@ -506,8 +497,7 @@ export default function Page() {
}) => (
<FormItem>
<FormLabel>
Domain
Type
{t('domainType')}
</FormLabel>
<Select
value={
@@ -531,11 +521,10 @@ export default function Page() {
</FormControl>
<SelectContent>
<SelectItem value="subdomain">
Subdomain
{t('subdomain')}
</SelectItem>
<SelectItem value="basedomain">
Base
Domain
{t('baseDomain')}
</SelectItem>
</SelectContent>
</Select>
@@ -550,7 +539,7 @@ export default function Page() {
) && (
<FormItem>
<FormLabel>
Subdomain
{t('subdomain')}
</FormLabel>
<div className="flex space-x-0">
<div className="w-1/2">
@@ -629,10 +618,7 @@ export default function Page() {
</div>
</div>
<FormDescription>
The subdomain
where your
resource will be
accessible.
{t('subdomnainDescription')}
</FormDescription>
</FormItem>
)}
@@ -650,8 +636,7 @@ export default function Page() {
}) => (
<FormItem>
<FormLabel>
Base
Domain
{t('baseDomain')}
</FormLabel>
<Select
onValueChange={
@@ -702,11 +687,10 @@ export default function Page() {
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
TCP/UDP Settings
{t('resourceRawSettings')}
</SettingsSectionTitle>
<SettingsSectionDescription>
Configure how your resource will be
accessed over TCP/UDP
{t('resourceRawSettingsDescription')}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
@@ -724,7 +708,7 @@ export default function Page() {
render={({ field }) => (
<FormItem>
<FormLabel>
Protocol
{t('protocol')}
</FormLabel>
<Select
onValueChange={
@@ -734,7 +718,7 @@ export default function Page() {
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a protocol" />
<SelectValue placeholder={t('protocolSelect')} />
</SelectTrigger>
</FormControl>
<SelectContent>
@@ -759,7 +743,7 @@ export default function Page() {
render={({ field }) => (
<FormItem>
<FormLabel>
Port Number
{t('resourcePortNumber')}
</FormLabel>
<FormControl>
<Input
@@ -787,10 +771,7 @@ export default function Page() {
</FormControl>
<FormMessage />
<FormDescription>
The external
port number
to proxy
requests.
{t('resourcePortNumberDescription')}
</FormDescription>
</FormItem>
)}
@@ -810,7 +791,7 @@ export default function Page() {
router.push(`/${orgId}/settings/resources`)
}
>
Cancel
{t('cancel')}
</Button>
<Button
type="button"
@@ -827,7 +808,7 @@ export default function Page() {
}}
loading={createLoading}
>
Create Resource
{t('resourceCreate')}
</Button>
</div>
</SettingsContainer>
@@ -836,17 +817,17 @@ export default function Page() {
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
Configuration Snippets
{t('resourceConfig')}
</SettingsSectionTitle>
<SettingsSectionDescription>
Copy and paste these configuration snippets to set up your TCP/UDP resource
{t('resourceConfigDescription')}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<div className="space-y-6">
<div className="space-y-4">
<h3 className="text-lg font-semibold">
Traefik: Add Entrypoints
{t('resourceAddEntrypoints')}
</h3>
<CopyTextBox
text={`entryPoints:
@@ -858,7 +839,7 @@ export default function Page() {
<div className="space-y-4">
<h3 className="text-lg font-semibold">
Gerbil: Expose Ports in Docker Compose
{t('resourceExposePorts')}
</h3>
<CopyTextBox
text={`ports:
@@ -874,7 +855,7 @@ export default function Page() {
rel="noopener noreferrer"
>
<span>
Learn how to configure TCP/UDP resources
{t('resourceLearnRaw')}
</span>
<SquareArrowOutUpRight size={14} />
</Link>
@@ -890,7 +871,7 @@ export default function Page() {
router.push(`/${orgId}/settings/resources`)
}
>
Back to Resources
{t('resourceBack')}
</Button>
<Button
type="button"
@@ -900,7 +881,7 @@ export default function Page() {
)
}
>
Go to Resource
{t('resourceGoTo')}
</Button>
</div>
</SettingsContainer>

View File

@@ -9,6 +9,7 @@ import { cache } from "react";
import { GetOrgResponse } from "@server/routers/org";
import OrgProvider from "@app/providers/OrgProvider";
import ResourcesSplashCard from "./ResourcesSplashCard";
import { getTranslations } from "next-intl/server";
type ResourcesPageProps = {
params: Promise<{ orgId: string }>;
@@ -18,6 +19,8 @@ export const dynamic = "force-dynamic";
export default async function ResourcesPage(props: ResourcesPageProps) {
const params = await props.params;
const t = await getTranslations();
let resources: ListResourcesResponse["resources"] = [];
try {
const res = await internal.get<AxiosResponse<ListResourcesResponse>>(
@@ -51,8 +54,8 @@ export default async function ResourcesPage(props: ResourcesPageProps) {
name: resource.name,
orgId: params.orgId,
domain: `${resource.ssl ? "https://" : "http://"}${resource.fullDomain}`,
site: resource.siteName || "None",
siteId: resource.siteId || "Unknown",
site: resource.siteName || t('none'),
siteId: resource.siteId || t('unknown'),
protocol: resource.protocol,
proxyPort: resource.proxyPort,
http: resource.http,
@@ -73,8 +76,8 @@ export default async function ResourcesPage(props: ResourcesPageProps) {
{/* <ResourcesSplashCard /> */}
<SettingsSectionTitle
title="Manage Resources"
description="Create secure proxies to your private applications"
title={t('resourceTitle')}
description={t('resourceDescription')}
/>
<OrgProvider org={org}>

View File

@@ -15,6 +15,7 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { useEnvContext } from "@app/hooks/useEnvContext";
import CopyToClipboard from "@app/components/CopyToClipboard";
import CopyTextBox from "@app/components/CopyTextBox";
import { useTranslations } from "next-intl";
interface AccessTokenSectionProps {
token: string;
@@ -37,37 +38,37 @@ export default function AccessTokenSection({
setTimeout(() => setCopied(null), 2000);
};
const t = useTranslations();
return (
<>
<div className="flex items-start space-x-2">
<p className="text-sm text-muted-foreground">
Your access token can be passed in two ways: as a query
parameter or in the request headers. These must be passed
from the client on every request for authenticated access.
{t('shareTokenDescription')}
</p>
</div>
<Tabs defaultValue="token" className="w-full mt-4">
<TabsList className="grid grid-cols-2">
<TabsTrigger value="token">Access Token</TabsTrigger>
<TabsTrigger value="usage">Usage Examples</TabsTrigger>
<TabsTrigger value="token">{t('accessToken')}</TabsTrigger>
<TabsTrigger value="usage">{t('usageExamples')}</TabsTrigger>
</TabsList>
<TabsContent value="token" className="space-y-4">
<div className="space-y-1">
<div className="font-bold">Token ID</div>
<div className="font-bold">{t('tokenId')}</div>
<CopyToClipboard text={tokenId} isLink={false} />
</div>
<div className="space-y-1">
<div className="font-bold">Token</div>
<div className="font-bold">{t('token')}</div>
<CopyToClipboard text={token} isLink={false} />
</div>
</TabsContent>
<TabsContent value="usage" className="space-y-4">
<div className="space-y-2">
<h3 className="text-sm font-medium">Request Headers</h3>
<h3 className="text-sm font-medium">{t('requestHeades')}</h3>
<CopyTextBox
text={`${env.server.resourceAccessTokenHeadersId}: ${tokenId}
${env.server.resourceAccessTokenHeadersToken}: ${token}`}
@@ -75,7 +76,7 @@ ${env.server.resourceAccessTokenHeadersToken}: ${token}`}
</div>
<div className="space-y-2">
<h3 className="text-sm font-medium">Query Parameter</h3>
<h3 className="text-sm font-medium">{t('queryParameter')}</h3>
<CopyTextBox
text={`${resourceUrl}?${env.server.resourceAccessTokenParam}=${tokenId}.${token}`}
/>
@@ -84,21 +85,17 @@ ${env.server.resourceAccessTokenHeadersToken}: ${token}`}
<Alert variant="neutral">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
Important Note
{t('importantNote')}
</AlertTitle>
<AlertDescription>
For security reasons, using headers is recommended
over query parameters when possible, as query
parameters may be logged in server logs or browser
history.
{t('shareImportantDescription')}
</AlertDescription>
</Alert>
</TabsContent>
</Tabs>
<div className="text-sm text-muted-foreground mt-4">
Keep your access token secure. Do not share it in publicly
accessible areas or client-side code.
{t('shareTokenSecurety')}
</div>
</>
);

View File

@@ -66,6 +66,7 @@ import {
CollapsibleTrigger
} from "@app/components/ui/collapsible";
import AccessTokenSection from "./AccessTokenUsage";
import { useTranslations } from "next-intl";
type FormProps = {
open: boolean;
@@ -73,15 +74,6 @@ type FormProps = {
onCreated?: (result: ShareLinkRow) => void;
};
const formSchema = z.object({
resourceId: z.number({ message: "Please select a resource" }),
resourceName: z.string(),
resourceUrl: z.string(),
timeUnit: z.string(),
timeValue: z.coerce.number().int().positive().min(1),
title: z.string().optional()
});
export default function CreateShareLinkForm({
open,
setOpen,
@@ -99,6 +91,7 @@ export default function CreateShareLinkForm({
const [neverExpire, setNeverExpire] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const t = useTranslations();
const [resources, setResources] = useState<
{
@@ -109,13 +102,22 @@ export default function CreateShareLinkForm({
}[]
>([]);
const formSchema = z.object({
resourceId: z.number({ message: t('shareErrorSelectResource') }),
resourceName: z.string(),
resourceUrl: z.string(),
timeUnit: z.string(),
timeValue: z.coerce.number().int().positive().min(1),
title: z.string().optional()
});
const timeUnits = [
{ unit: "minutes", name: "Minutes" },
{ unit: "hours", name: "Hours" },
{ unit: "days", name: "Days" },
{ unit: "weeks", name: "Weeks" },
{ unit: "months", name: "Months" },
{ unit: "years", name: "Years" }
{ unit: "minutes", name: t('minutes') },
{ unit: "hours", name: t('hours') },
{ unit: "days", name: t('days') },
{ unit: "weeks", name: t('weeks') },
{ unit: "months", name: t('months') },
{ unit: "years", name: t('years') }
];
const form = useForm<z.infer<typeof formSchema>>({
@@ -141,10 +143,10 @@ export default function CreateShareLinkForm({
console.error(e);
toast({
variant: "destructive",
title: "Failed to fetch resources",
title: t('shareErrorFetchResource'),
description: formatAxiosError(
e,
"An error occurred while fetching the resources"
t('shareErrorFetchResourceDescription')
)
});
});
@@ -201,17 +203,17 @@ export default function CreateShareLinkForm({
validForSeconds: neverExpire ? undefined : timeInSeconds,
title:
values.title ||
`${values.resourceName || "Resource" + values.resourceId} Share Link`
t('shareLink', {resource: (values.resourceName || "Resource" + values.resourceId)})
}
)
.catch((e) => {
console.error(e);
toast({
variant: "destructive",
title: "Failed to create share link",
title: t('shareErrorCreate'),
description: formatAxiosError(
e,
"An error occurred while creating the share link"
t('shareErrorCreateDescription')
)
});
});
@@ -260,9 +262,9 @@ export default function CreateShareLinkForm({
>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>Create Shareable Link</CredenzaTitle>
<CredenzaTitle>{t('shareCreate')}</CredenzaTitle>
<CredenzaDescription>
Anyone with this link can access the resource
{t('shareCreateDescription')}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
@@ -280,7 +282,7 @@ export default function CreateShareLinkForm({
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>
Resource
{t('resource')}
</FormLabel>
<Popover>
<PopoverTrigger asChild>
@@ -298,19 +300,17 @@ export default function CreateShareLinkForm({
? getSelectedResourceName(
field.value
)
: "Select resource"}
: t('resourceSelect')}
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="p-0">
<Command>
<CommandInput placeholder="Search resources" />
<CommandInput placeholder={t('resourceSearch')} />
<CommandList>
<CommandEmpty>
No
resources
found
{t('resourcesNotFound')}
</CommandEmpty>
<CommandGroup>
{resources.map(
@@ -366,7 +366,7 @@ export default function CreateShareLinkForm({
render={({ field }) => (
<FormItem>
<FormLabel>
Title (optional)
{t('shareTitleOptional')}
</FormLabel>
<FormControl>
<Input {...field} />
@@ -378,7 +378,7 @@ export default function CreateShareLinkForm({
<div className="space-y-4">
<div className="space-y-2">
<FormLabel>Expire In</FormLabel>
<FormLabel>{t('expireIn')}</FormLabel>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
@@ -393,7 +393,7 @@ export default function CreateShareLinkForm({
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select duration" />
<SelectValue placeholder={t('selectDuration')} />
</SelectTrigger>
</FormControl>
<SelectContent>
@@ -455,18 +455,12 @@ export default function CreateShareLinkForm({
htmlFor="terms"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Never expire
{t('neverExpire')}
</label>
</div>
<p className="text-sm text-muted-foreground">
Expiration time is how long the
link will be usable and provide
access to the resource. After
this time, the link will no
longer work, and users who used
this link will lose access to
the resource.
{t('shareExpireDescription')}
</p>
</div>
</form>
@@ -475,12 +469,10 @@ export default function CreateShareLinkForm({
{link && (
<div className="max-w-md space-y-4">
<p>
You will only be able to see this link
once. Make sure to copy it.
{t('shareSeeOnce')}
</p>
<p>
Anyone with this link can access the
resource. Share it with care.
{t('shareAccessHint')}
</p>
<div className="h-[250px] w-full mx-auto flex items-center justify-center">
@@ -506,12 +498,12 @@ export default function CreateShareLinkForm({
className="p-0 flex items-center justify-between w-full"
>
<h4 className="text-sm font-semibold">
See Access Token Usage
{t('shareTokenUsage')}
</h4>
<div>
<ChevronsUpDown className="h-4 w-4" />
<span className="sr-only">
Toggle
{t('toggle')}
</span>
</div>
</Button>
@@ -541,7 +533,7 @@ export default function CreateShareLinkForm({
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
<Button variant="outline">{t('close')}</Button>
</CredenzaClose>
<Button
type="button"
@@ -549,7 +541,7 @@ export default function CreateShareLinkForm({
loading={loading}
disabled={link !== null || loading}
>
Create Link
{t('createLink')}
</Button>
</CredenzaFooter>
</CredenzaContent>

View File

@@ -4,6 +4,7 @@ import {
ColumnDef,
} from "@tanstack/react-table";
import { DataTable } from "@app/components/ui/data-table";
import { useTranslations } from "next-intl";
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
@@ -16,15 +17,18 @@ export function ShareLinksDataTable<TData, TValue>({
data,
createShareLink
}: DataTableProps<TData, TValue>) {
const t = useTranslations();
return (
<DataTable
columns={columns}
data={data}
title="Share Links"
searchPlaceholder="Search share links..."
title={t('shareLinks')}
searchPlaceholder={t('shareSearch')}
searchColumn="name"
onAdd={createShareLink}
addButtonText="Create Share Link"
addButtonText={t('shareCreate')}
/>
);
}

View File

@@ -4,6 +4,7 @@ import React, { useState, useEffect } from "react";
import { Link, X, Clock, Share, ArrowRight, Lock } from "lucide-react"; // Replace with actual imports
import { Card, CardContent } from "@app/components/ui/card";
import { Button } from "@app/components/ui/button";
import { useTranslations } from "next-intl";
export const ShareableLinksSplash = () => {
const [isDismissed, setIsDismissed] = useState(false);
@@ -22,6 +23,8 @@ export const ShareableLinksSplash = () => {
localStorage.setItem(key, "true");
};
const t = useTranslations();
if (isDismissed) {
return null;
}
@@ -31,7 +34,7 @@ export const ShareableLinksSplash = () => {
<button
onClick={handleDismiss}
className="absolute top-2 right-2 p-2"
aria-label="Dismiss"
aria-label={t('dismiss')}
>
<X className="w-5 h-5" />
</button>
@@ -39,26 +42,23 @@ export const ShareableLinksSplash = () => {
<div className="space-y-4">
<h3 className="text-xl font-semibold flex items-center gap-2">
<Link className="text-blue-500" />
Shareable Links
{t('share')}
</h3>
<p className="text-sm">
Create shareable links to your resources. Links provide
temporary or unlimited access to your resource. You can
configure the expiration duration of the link when you
create one.
{t('shareDescription2')}
</p>
<ul className="text-sm text-muted-foreground space-y-2">
<li className="flex items-center gap-2">
<Share className="text-green-500 w-4 h-4" />
Easy to create and share
{t('shareEasyCreate')}
</li>
<li className="flex items-center gap-2">
<Clock className="text-yellow-500 w-4 h-4" />
Configurable expiration duration
{t('shareConfigurableExpirationDuration')}
</li>
<li className="flex items-center gap-2">
<Lock className="text-red-500 w-4 h-4" />
Secure and revocable
{t('shareSecureAndRevocable')}
</li>
</ul>
</div>

View File

@@ -33,6 +33,7 @@ import { ListAccessTokensResponse } from "@server/routers/accessToken";
import moment from "moment";
import CreateShareLinkForm from "./CreateShareLinkForm";
import { constructShareLink } from "@app/lib/shareLinks";
import { useTranslations } from "next-intl";
export type ShareLinkRow = {
accessTokenId: string;
@@ -54,6 +55,7 @@ export default function ShareLinksTable({
orgId
}: ShareLinksTableProps) {
const router = useRouter();
const t = useTranslations();
const api = createApiClient(useEnvContext());
@@ -67,10 +69,10 @@ export default function ShareLinksTable({
async function deleteSharelink(id: string) {
await api.delete(`/access-token/${id}`).catch((e) => {
toast({
title: "Failed to delete link",
title: t('shareErrorDelete'),
description: formatAxiosError(
e,
"An error occurred deleting link"
t('shareErrorDeleteMessage')
)
});
});
@@ -79,8 +81,8 @@ export default function ShareLinksTable({
setRows(newRows);
toast({
title: "Link deleted",
description: "The link has been deleted"
title: t('shareDeleted'),
description: t('shareDeletedDescription')
});
}
@@ -102,7 +104,7 @@ export default function ShareLinksTable({
className="h-8 w-8 p-0"
>
<span className="sr-only">
Open menu
{t('openMenu')}
</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
@@ -116,7 +118,7 @@ export default function ShareLinksTable({
}}
>
<button className="text-red-500">
Delete
{t('delete')}
</button>
</DropdownMenuItem>
</DropdownMenuContent>
@@ -136,7 +138,7 @@ export default function ShareLinksTable({
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Resource
{t('resource')}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
@@ -164,7 +166,7 @@ export default function ShareLinksTable({
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Title
{t('title')}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
@@ -243,7 +245,7 @@ export default function ShareLinksTable({
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Created
{t('created')}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
@@ -263,7 +265,7 @@ export default function ShareLinksTable({
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Expires
{t('expires')}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
@@ -273,7 +275,7 @@ export default function ShareLinksTable({
if (r.expiresAt) {
return moment(r.expiresAt).format("lll");
}
return "Never";
return t('never');
}
},
{
@@ -286,7 +288,7 @@ export default function ShareLinksTable({
deleteSharelink(row.original.accessTokenId)
}
>
Delete
{t('delete')}
</Button>
</div>
)

View File

@@ -9,6 +9,7 @@ import OrgProvider from "@app/providers/OrgProvider";
import { ListAccessTokensResponse } from "@server/routers/accessToken";
import ShareLinksTable, { ShareLinkRow } from "./ShareLinksTable";
import ShareableLinksSplash from "./ShareLinksSplash";
import { getTranslations } from "next-intl/server";
type ShareLinksPageProps = {
params: Promise<{ orgId: string }>;
@@ -51,13 +52,15 @@ export default async function ShareLinksPage(props: ShareLinksPageProps) {
(token) => ({ ...token }) as ShareLinkRow
);
const t = await getTranslations();
return (
<>
{/* <ShareableLinksSplash /> */}
<SettingsSectionTitle
title="Manage Share Links"
description="Create shareable links to grant temporary or permanent access to your resources"
title={t('shareTitle')}
description={t('shareDescription')}
/>
<OrgProvider org={org}>

View File

@@ -50,25 +50,7 @@ import {
CollapsibleTrigger
} from "@app/components/ui/collapsible";
import LoaderPlaceholder from "@app/components/PlaceHolderLoader";
const createSiteFormSchema = z.object({
name: z
.string()
.min(2, {
message: "Name must be at least 2 characters."
})
.max(30, {
message: "Name must not be longer than 30 characters."
}),
method: z.enum(["wireguard", "newt", "local"])
});
type CreateSiteFormValues = z.infer<typeof createSiteFormSchema>;
const defaultValues: Partial<CreateSiteFormValues> = {
name: "",
method: "newt"
};
import { useTranslations } from "next-intl";
type CreateSiteFormProps = {
onCreate?: (site: SiteRow) => void;
@@ -96,6 +78,27 @@ export default function CreateSiteForm({
privateKey: string;
} | null>(null);
const t = useTranslations();
const createSiteFormSchema = z.object({
name: z
.string()
.min(2, {
message: t('nameMin', {len: 2})
})
.max(30, {
message: t('nameMax', {len: 30})
}),
method: z.enum(["wireguard", "newt", "local"])
});
type CreateSiteFormValues = z.infer<typeof createSiteFormSchema>;
const defaultValues: Partial<CreateSiteFormValues> = {
name: "",
method: "newt"
};
const [siteDefaults, setSiteDefaults] =
useState<PickSiteDefaultsResponse | null>(null);
@@ -169,8 +172,8 @@ export default function CreateSiteForm({
if (!keypair || !siteDefaults) {
toast({
variant: "destructive",
title: "Error creating site",
description: "Key pair or site defaults not found"
title: t('siteErrorCreate'),
description: t('siteErrorCreateKeyPair')
});
setLoading?.(false);
setIsLoading(false);
@@ -188,8 +191,8 @@ export default function CreateSiteForm({
if (!siteDefaults) {
toast({
variant: "destructive",
title: "Error creating site",
description: "Site defaults not found"
title: t('siteErrorCreate'),
description: t('siteErrorCreateDefaults')
});
setLoading?.(false);
setIsLoading(false);
@@ -212,7 +215,7 @@ export default function CreateSiteForm({
.catch((e) => {
toast({
variant: "destructive",
title: "Error creating site",
title: t('siteErrorCreate'),
description: formatAxiosError(e)
});
});
@@ -227,11 +230,11 @@ export default function CreateSiteForm({
address: data.address?.split("/")[0],
mbIn:
data.type == "wireguard" || data.type == "newt"
? "0 MB"
? t('megabytes', {count: 0})
: "-",
mbOut:
data.type == "wireguard" || data.type == "newt"
? "0 MB"
? t('megabytes', {count: 0})
: "-",
orgId: orgId as string,
type: data.type as any,
@@ -286,13 +289,13 @@ PersistentKeepalive = 5`
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormLabel>{t('name')}</FormLabel>
<FormControl>
<Input autoComplete="off" {...field} />
</FormControl>
<FormMessage />
<FormDescription>
This is the display name for the site.
{t('siteNameDescription')}
</FormDescription>
</FormItem>
)}
@@ -302,18 +305,18 @@ PersistentKeepalive = 5`
name="method"
render={({ field }) => (
<FormItem>
<FormLabel>Method</FormLabel>
<FormLabel>{t('method')}</FormLabel>
<FormControl>
<Select
value={field.value}
onValueChange={field.onChange}
>
<SelectTrigger>
<SelectValue placeholder="Select method" />
<SelectValue placeholder={t('methodSelect')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="local">
Local
{t('local')}
</SelectItem>
<SelectItem
value="newt"
@@ -332,7 +335,7 @@ PersistentKeepalive = 5`
</FormControl>
<FormMessage />
<FormDescription>
This is how you will expose connections.
{t('siteMethodDescription')}
</FormDescription>
</FormItem>
)}
@@ -346,7 +349,7 @@ PersistentKeepalive = 5`
rel="noopener noreferrer"
>
<span>
Learn how to install Newt on your system
{t('siteLearnNewt')}
</span>
<SquareArrowOutUpRight size={14} />
</Link>
@@ -357,13 +360,12 @@ PersistentKeepalive = 5`
<>
<CopyTextBox text={wgConfig} />
<span className="text-sm text-muted-foreground mt-2">
You will only be able to see the
configuration once.
{t('siteSeeConfigOnce')}
</span>
</>
) : form.watch("method") === "wireguard" &&
isLoading ? (
<p>Loading WireGuard configuration...</p>
<p>{t('siteLoadWGConfig')}</p>
) : form.watch("method") === "newt" && siteDefaults ? (
<>
<div className="mb-2">
@@ -379,8 +381,7 @@ PersistentKeepalive = 5`
/>
</div>
<span className="text-sm text-muted-foreground">
You will only be able to see the
configuration once.
{t('siteSeeConfigOnce')}
</span>
<div className="flex items-center justify-between space-x-4">
<CollapsibleTrigger asChild>
@@ -390,13 +391,12 @@ PersistentKeepalive = 5`
className="p-0 flex items-center justify-between w-full"
>
<h4 className="text-sm font-semibold">
Expand for Docker
Deployment Details
{t('siteDocker')}
</h4>
<div>
<ChevronsUpDown className="h-4 w-4" />
<span className="sr-only">
Toggle
{t('toggle')}
</span>
</div>
</Button>
@@ -404,7 +404,7 @@ PersistentKeepalive = 5`
</div>
<CollapsibleContent className="space-y-4">
<div className="space-y-2">
<b>Docker Compose</b>
<b>{t('dockerCompose')}</b>
<CopyTextBox
text={
newtConfigDockerCompose
@@ -413,7 +413,7 @@ PersistentKeepalive = 5`
/>
</div>
<div className="space-y-2">
<b>Docker Run</b>
<b>{t('dockerRun')}</b>
<CopyTextBox
text={newtConfigDockerRun}
@@ -434,7 +434,7 @@ PersistentKeepalive = 5`
target="_blank"
rel="noopener noreferrer"
>
<span> Local sites do not tunnel, learn more</span>
<span>{t('siteLearnLocal')}</span>
<SquareArrowOutUpRight size={14} />
</Link>
)}
@@ -451,7 +451,7 @@ PersistentKeepalive = 5`
htmlFor="terms"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
I have copied the config
{t('siteConfirmCopy')}
</label>
</div>
)}

View File

@@ -14,6 +14,7 @@ import {
} from "@app/components/Credenza";
import { SiteRow } from "./SitesTable";
import CreateSiteForm from "./CreateSiteForm";
import { useTranslations } from "next-intl";
type CreateSiteFormProps = {
open: boolean;
@@ -30,6 +31,7 @@ export default function CreateSiteFormModal({
}: CreateSiteFormProps) {
const [loading, setLoading] = useState(false);
const [isChecked, setIsChecked] = useState(false);
const t = useTranslations();
return (
<>
@@ -42,9 +44,9 @@ export default function CreateSiteFormModal({
>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>Create Site</CredenzaTitle>
<CredenzaTitle>{t('siteCreate')}</CredenzaTitle>
<CredenzaDescription>
Create a new site to start connecting your resources
{t('siteCreateDescription')}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
@@ -59,7 +61,7 @@ export default function CreateSiteFormModal({
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
<Button variant="outline">{t('close')}</Button>
</CredenzaClose>
<Button
type="submit"
@@ -70,7 +72,7 @@ export default function CreateSiteFormModal({
setOpen(false);
}}
>
Create Site
{t('siteCreate')}
</Button>
</CredenzaFooter>
</CredenzaContent>

View File

@@ -2,6 +2,7 @@
import { ColumnDef } from "@tanstack/react-table";
import { DataTable } from "@app/components/ui/data-table";
import { useTranslations } from "next-intl";
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
@@ -14,15 +15,18 @@ export function SitesDataTable<TData, TValue>({
data,
createSite
}: DataTableProps<TData, TValue>) {
const t = useTranslations();
return (
<DataTable
columns={columns}
data={data}
title="Sites"
searchPlaceholder="Search sites..."
title={t('sites')}
searchPlaceholder={t('searchSitesProgress')}
searchColumn="name"
onAdd={createSite}
addButtonText="Add Site"
addButtonText={t('siteAdd')}
defaultSort={{
id: "name",
desc: false

View File

@@ -5,11 +5,13 @@ import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { ArrowRight, DockIcon as Docker, Globe, Server, X } from "lucide-react";
import Link from "next/link";
import { useTranslations } from 'next-intl';
export const SitesSplashCard = () => {
const [isDismissed, setIsDismissed] = useState(true);
const key = "sites-splash-card-dismissed";
const t = useTranslations();
useEffect(() => {
const dismissed = localStorage.getItem(key);
@@ -34,7 +36,7 @@ export const SitesSplashCard = () => {
<button
onClick={handleDismiss}
className="absolute top-2 right-2 p-2"
aria-label="Dismiss"
aria-label={t('dismiss')}
>
<X className="w-5 h-5" />
</button>
@@ -42,22 +44,19 @@ export const SitesSplashCard = () => {
<div className="space-y-4">
<h3 className="text-xl font-semibold flex items-center gap-2">
<Globe className="text-blue-500" />
Newt (Recommended)
Newt ({t('recommended')})
</h3>
<p className="text-sm">
For the best user experience, use Newt. It uses
WireGuard under the hood and allows you to address your
private resources by their LAN address on your private
network from within the Pangolin dashboard.
{t('siteNewtDescription')}
</p>
<ul className="text-sm text-muted-foreground space-y-2">
<li className="flex items-center gap-2">
<Server className="text-green-500 w-4 h-4" />
Runs in Docker
{t('siteRunsInDocker')}
</li>
<li className="flex items-center gap-2">
<Server className="text-green-500 w-4 h-4" />
Runs in shell on macOS, Linux, and Windows
{t('siteRunsInShell')}
</li>
</ul>
@@ -71,7 +70,7 @@ export const SitesSplashCard = () => {
className="w-full flex items-center"
variant="secondary"
>
Install Newt{" "}
{t('siteInstallNewt')}{" "}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</Link>
@@ -79,20 +78,19 @@ export const SitesSplashCard = () => {
</div>
<div className="space-y-4">
<h3 className="text-xl font-semibold flex items-center gap-2">
Basic WireGuard
{t('siteWg')}
</h3>
<p className="text-sm">
Use any WireGuard client to connect. You will have to
address your internal resources using the peer IP.
{t('siteWgAnyClients')}
</p>
<ul className="text-sm text-muted-foreground space-y-2">
<li className="flex items-center gap-2">
<Docker className="text-purple-500 w-4 h-4" />
Compatible with all WireGuard clients
{t('siteWgCompatibleAllClients')}
</li>
<li className="flex items-center gap-2">
<Server className="text-purple-500 w-4 h-4" />
Manual configuration required
{t('siteWgManualConfigurationRequired')}
</li>
</ul>
</div>

View File

@@ -27,6 +27,7 @@ import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import CreateSiteFormModal from "./CreateSiteModal";
import { useTranslations } from "next-intl";
import { parseDataSize } from '@app/lib/dataSize';
export type SiteRow = {
@@ -54,15 +55,16 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
const [rows, setRows] = useState<SiteRow[]>(sites);
const api = createApiClient(useEnvContext());
const t = useTranslations();
const deleteSite = (siteId: number) => {
api.delete(`/site/${siteId}`)
.catch((e) => {
console.error("Error deleting site", e);
console.error(t('siteErrorDelete'), e);
toast({
variant: "destructive",
title: "Error deleting site",
description: formatAxiosError(e, "Error deleting site")
title: t('siteErrorDelete'),
description: formatAxiosError(e, t('siteErrorDelete'))
});
})
.then(() => {
@@ -96,7 +98,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
href={`/${siteRow.orgId}/settings/sites/${siteRow.nice}`}
>
<DropdownMenuItem>
View settings
{t('viewSettings')}
</DropdownMenuItem>
</Link>
<DropdownMenuItem
@@ -105,7 +107,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
setIsDeleteModalOpen(true);
}}
>
<span className="text-red-500">Delete</span>
<span className="text-red-500">{t('delete')}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -122,7 +124,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Name
{t('name')}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
@@ -138,7 +140,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Online
{t('online')}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
@@ -153,14 +155,14 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
return (
<span className="text-green-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span>Online</span>
<span>{t('online')}</span>
</span>
);
} else {
return (
<span className="text-neutral-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-gray-500 rounded-full"></div>
<span>Offline</span>
<span>{t('offline')}</span>
</span>
);
}
@@ -179,7 +181,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Site
{t('site')}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
@@ -195,7 +197,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Data In
{t('dataIn')}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
@@ -213,7 +215,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Data Out
{t('dataOut')}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
@@ -231,7 +233,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Connection Type
{t('connectionType')}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
@@ -258,7 +260,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
if (originalRow.type === "local") {
return (
<div className="flex items-center space-x-2">
<span>Local</span>
<span>{t('local')}</span>
</div>
);
}
@@ -290,7 +292,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
href={`/${siteRow.orgId}/settings/sites/${siteRow.nice}`}
>
<Button variant={"outlinePrimary"} className="ml-2">
Edit
{t('edit')}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</Link>
@@ -312,30 +314,22 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
dialog={
<div className="space-y-4">
<p>
Are you sure you want to remove the site{" "}
<b>{selectedSite?.name || selectedSite?.id}</b>{" "}
from the organization?
{t('siteQuestionRemove', {selectedSite: selectedSite?.name || selectedSite?.id})}
</p>
<p>
{t('siteMessageRemove')}
</p>
<p>
Once removed, the site will no longer be
accessible.{" "}
<b>
All resources and targets associated with
the site will also be removed.
</b>
</p>
<p>
To confirm, please type the name of the site
below.
{t('siteMessageConfirm')}
</p>
</div>
}
buttonText="Confirm Delete Site"
buttonText={t('siteConfirmDelete')}
onConfirm={async () => deleteSite(selectedSite!.id)}
string={selectedSite.name}
title="Delete Site"
title={t('siteDelete')}
/>
)}

View File

@@ -9,11 +9,13 @@ import {
InfoSections,
InfoSectionTitle
} from "@app/components/InfoSection";
import { useTranslations } from "next-intl";
type SiteInfoCardProps = {};
export default function SiteInfoCard({}: SiteInfoCardProps) {
const { site, updateSite } = useSiteContext();
const t = useTranslations();
const getConnectionTypeString = (type: string) => {
if (type === "newt") {
@@ -21,32 +23,32 @@ export default function SiteInfoCard({}: SiteInfoCardProps) {
} else if (type === "wireguard") {
return "WireGuard";
} else if (type === "local") {
return "Local";
return t('local');
} else {
return "Unknown";
return t('unknown');
}
};
return (
<Alert>
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">Site Information</AlertTitle>
<AlertTitle className="font-semibold">{t('siteInfo')}</AlertTitle>
<AlertDescription className="mt-4">
<InfoSections cols={2}>
{(site.type == "newt" || site.type == "wireguard") && (
<>
<InfoSection>
<InfoSectionTitle>Status</InfoSectionTitle>
<InfoSectionTitle>{t('status')}</InfoSectionTitle>
<InfoSectionContent>
{site.online ? (
<div className="text-green-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span>Online</span>
<span>{t('online')}</span>
</div>
) : (
<div className="text-neutral-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-gray-500 rounded-full"></div>
<span>Offline</span>
<span>{t('offline')}</span>
</div>
)}
</InfoSectionContent>
@@ -54,7 +56,7 @@ export default function SiteInfoCard({}: SiteInfoCardProps) {
</>
)}
<InfoSection>
<InfoSectionTitle>Connection Type</InfoSectionTitle>
<InfoSectionTitle>{t('connectionType')}</InfoSectionTitle>
<InfoSectionContent>
{getConnectionTypeString(site.type)}
</InfoSectionContent>

View File

@@ -32,8 +32,8 @@ import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useState } from "react";
import { SwitchInput } from "@app/components/SwitchInput";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { ArrowRight, ExternalLink } from "lucide-react";
const GeneralFormSchema = z.object({
name: z.string().nonempty("Name is required"),
@@ -50,6 +50,7 @@ export default function GeneralPage() {
const [loading, setLoading] = useState(false);
const router = useRouter();
const t = useTranslations();
const form = useForm<GeneralFormValues>({
resolver: zodResolver(GeneralFormSchema),
@@ -71,10 +72,10 @@ export default function GeneralPage() {
.catch((e) => {
toast({
variant: "destructive",
title: "Failed to update site",
title: t("siteErrorUpdate"),
description: formatAxiosError(
e,
"An error occurred while updating the site."
t("siteErrorUpdateDescription")
)
});
});
@@ -85,8 +86,8 @@ export default function GeneralPage() {
});
toast({
title: "Site updated",
description: "The site has been updated."
title: t("siteUpdated"),
description: t("siteUpdatedDescription")
});
setLoading(false);
@@ -99,10 +100,10 @@ export default function GeneralPage() {
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
General Settings
{t("generalSettings")}
</SettingsSectionTitle>
<SettingsSectionDescription>
Configure the general settings for this site
{t("siteGeneralDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
@@ -119,14 +120,13 @@ export default function GeneralPage() {
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormLabel>{t("name")}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
<FormDescription>
This is the display name of the
site.
{t("siteNameDescription")}
</FormDescription>
</FormItem>
)}
@@ -140,7 +140,9 @@ export default function GeneralPage() {
<FormControl>
<SwitchInput
id="docker-socket-enabled"
label="Enable Docker Socket"
label={t(
"enableDockerSocket"
)}
defaultChecked={
field.value
}
@@ -151,20 +153,21 @@ export default function GeneralPage() {
</FormControl>
<FormMessage />
<FormDescription>
Enable Docker Socket
discovery for populating
container information.
Socket path must be provided
to Newt.{" "}
<a
{t(
"enableDockerSocketDescription"
)}{" "}
<Link
href="https://docs.fossorial.io/Newt/overview#docker-socket-integration"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center"
>
Learn more
<ExternalLink className="ml-1 h-4 w-4" />
</a>
<span>
{t(
"enableDockerSocketLink"
)}
</span>
</Link>
</FormDescription>
</FormItem>
)}
@@ -182,7 +185,7 @@ export default function GeneralPage() {
loading={loading}
disabled={loading}
>
Save General Settings
{t("saveGeneralSettings")}
</Button>
</SettingsSectionFooter>
</SettingsSection>

View File

@@ -16,6 +16,7 @@ import {
BreadcrumbSeparator
} from "@app/components/ui/breadcrumb";
import SiteInfoCard from "./SiteInfoCard";
import { getTranslations } from "next-intl/server";
interface SettingsLayoutProps {
children: React.ReactNode;
@@ -38,9 +39,11 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
redirect(`/${params.orgId}/settings/sites`);
}
const t = await getTranslations();
const navItems = [
{
title: "General",
title: t('general'),
href: "/{orgId}/settings/sites/{niceId}/general"
}
];
@@ -48,8 +51,8 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
return (
<>
<SettingsSectionTitle
title={`${site?.name} Settings`}
description="Configure the settings on your site"
title={t('siteSetting', {siteName: site?.name})}
description={t('siteSettingDescription')}
/>
<SiteProvider site={site}>

View File

@@ -65,32 +65,7 @@ import {
import Link from "next/link";
import { QRCodeCanvas } from "qrcode.react";
const createSiteFormSchema = z
.object({
name: z
.string()
.min(2, { message: "Name must be at least 2 characters." })
.max(30, {
message: "Name must not be longer than 30 characters."
}),
method: z.enum(["newt", "wireguard", "local"]),
copied: z.boolean(),
clientAddress: z.string().optional()
})
.refine(
(data) => {
if (data.method !== "local") {
return data.copied;
}
return true;
},
{
message: "Please confirm that you have copied the config.",
path: ["copied"]
}
);
type CreateSiteFormValues = z.infer<typeof createSiteFormSchema>;
import { useTranslations } from "next-intl";
type SiteType = "newt" | "wireguard" | "local";
@@ -125,28 +100,54 @@ export default function Page() {
const api = createApiClient({ env });
const { orgId } = useParams();
const router = useRouter();
const t = useTranslations();
const createSiteFormSchema = z
.object({
name: z
.string()
.min(2, { message: t('nameMin', {len: 2}) })
.max(30, {
message: t('nameMax', {len: 30})
}),
method: z.enum(["newt", "wireguard", "local"]),
copied: z.boolean(),
clientAddress: z.string().optional()
})
.refine(
(data) => {
if (data.method !== "local") {
return data.copied;
}
return true;
},
{
message: t('sitesConfirmCopy'),
path: ["copied"]
}
);
type CreateSiteFormValues = z.infer<typeof createSiteFormSchema>;
const [tunnelTypes, setTunnelTypes] = useState<
ReadonlyArray<TunnelTypeOption>
>([
{
id: "newt",
title: "Newt Tunnel (Recommended)",
description:
"Easiest way to create an entrypoint into your network. No extra setup.",
title: t('siteNewtTunnel'),
description: t('siteNewtTunnelDescription'),
disabled: true
},
{
id: "wireguard",
title: "Basic WireGuard",
description:
"Use any WireGuard client to establish a tunnel. Manual NAT setup required.",
title: t('siteWg'),
description: t('siteWgDescription'),
disabled: true
},
{
id: "local",
title: "Local",
description: "Local resources only. No tunneling."
title: t('local'),
description: t('siteLocalDescription')
}
]);
@@ -325,7 +326,7 @@ WantedBy=default.target`
};
const getCommand = () => {
const placeholder = ["Unknown command"];
const placeholder = [t('unknownCommand')];
if (!commands) {
return placeholder;
}
@@ -387,8 +388,8 @@ WantedBy=default.target`
if (!siteDefaults || !wgConfig) {
toast({
variant: "destructive",
title: "Error creating site",
description: "Key pair or site defaults not found"
title: t('siteErrorCreate'),
description: t('siteErrorCreateKeyPair')
});
setCreateLoading(false);
return;
@@ -405,8 +406,8 @@ WantedBy=default.target`
if (!siteDefaults) {
toast({
variant: "destructive",
title: "Error creating site",
description: "Site defaults not found"
title: t('siteErrorCreate'),
description: t('siteErrorCreateDefaults')
});
setCreateLoading(false);
return;
@@ -429,7 +430,7 @@ WantedBy=default.target`
.catch((e) => {
toast({
variant: "destructive",
title: "Error creating site",
title: t('siteErrorCreate'),
description: formatAxiosError(e)
});
});
@@ -455,14 +456,14 @@ WantedBy=default.target`
);
if (!response.ok) {
throw new Error(
`Failed to fetch release info: ${response.statusText}`
t('newtErrorFetchReleases', {err: response.statusText})
);
}
const data = await response.json();
const latestVersion = data.tag_name;
newtVersion = latestVersion;
} catch (error) {
console.error("Error fetching latest release:", error);
console.error(t('newtErrorFetchLatest', {err: error instanceof Error ? error.message : String(error)}));
}
const generatedKeypair = generateKeypair();
@@ -529,8 +530,8 @@ WantedBy=default.target`
<>
<div className="flex justify-between">
<HeaderTitle
title="Create Site"
description="Follow the steps below to create and connect a new site"
title={t('siteCreate')}
description={t('siteCreateDescription2')}
/>
<Button
variant="outline"
@@ -538,7 +539,7 @@ WantedBy=default.target`
router.push(`/${orgId}/settings/sites`);
}}
>
See All Sites
{t('siteSeeAll')}
</Button>
</div>
@@ -548,7 +549,7 @@ WantedBy=default.target`
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
Site Information
{t('siteInfo')}
</SettingsSectionTitle>
</SettingsSectionHeader>
<SettingsSectionBody>
@@ -564,7 +565,7 @@ WantedBy=default.target`
render={({ field }) => (
<FormItem>
<FormLabel>
Name
{t('name')}
</FormLabel>
<FormControl>
<Input
@@ -574,8 +575,7 @@ WantedBy=default.target`
</FormControl>
<FormMessage />
<FormDescription>
This is the display
name for the site.
{t('siteNameDescription')}
</FormDescription>
</FormItem>
)}
@@ -625,11 +625,10 @@ WantedBy=default.target`
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
Tunnel Type
{t('tunnelType')}
</SettingsSectionTitle>
<SettingsSectionDescription>
Determine how you want to connect to your
site
{t('siteTunnelDescription')}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
@@ -649,18 +648,17 @@ WantedBy=default.target`
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
Newt Credentials
{t('siteNewtCredentials')}
</SettingsSectionTitle>
<SettingsSectionDescription>
This is how Newt will authenticate
with the server
{t('siteNewtCredentialsDescription')}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<InfoSections cols={3}>
<InfoSection>
<InfoSectionTitle>
Newt Endpoint
{t('newtEndpoint')}
</InfoSectionTitle>
<InfoSectionContent>
<CopyToClipboard
@@ -672,7 +670,7 @@ WantedBy=default.target`
</InfoSection>
<InfoSection>
<InfoSectionTitle>
Newt ID
{t('newtId')}
</InfoSectionTitle>
<InfoSectionContent>
<CopyToClipboard
@@ -682,7 +680,7 @@ WantedBy=default.target`
</InfoSection>
<InfoSection>
<InfoSectionTitle>
Newt Secret Key
{t('newtSecretKey')}
</InfoSectionTitle>
<InfoSectionContent>
<CopyToClipboard
@@ -695,12 +693,10 @@ WantedBy=default.target`
<Alert variant="neutral" className="">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
Save Your Credentials
{t('siteCredentialsSave')}
</AlertTitle>
<AlertDescription>
You will only be able to see
this once. Make sure to copy it
to a secure place.
{t('siteCredentialsSaveDescription')}
</AlertDescription>
</Alert>
@@ -735,9 +731,7 @@ WantedBy=default.target`
htmlFor="terms"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
I have
copied the
config
{t('siteConfirmCopy')}
</label>
</div>
<FormMessage />
@@ -751,16 +745,16 @@ WantedBy=default.target`
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
Install Newt
{t('siteInstallNewt')}
</SettingsSectionTitle>
<SettingsSectionDescription>
Get Newt running on your system
{t('siteInstallNewtDescription')}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<div>
<p className="font-bold mb-3">
Operating System
{t('operatingSystem')}
</p>
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
{platforms.map((os) => (
@@ -788,8 +782,8 @@ WantedBy=default.target`
{["docker", "podman"].includes(
platform
)
? "Method"
: "Architecture"}
? t('method')
: t('architecture')}
</p>
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
{getArchitectures().map(
@@ -816,7 +810,7 @@ WantedBy=default.target`
</div>
<div className="pt-4">
<p className="font-bold mb-3">
Commands
{t('commands')}
</p>
<div className="mt-2">
<CopyTextBox
@@ -837,11 +831,10 @@ WantedBy=default.target`
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
WireGuard Configuration
{t('WgConfiguration')}
</SettingsSectionTitle>
<SettingsSectionDescription>
Use the following configuration to
connect to your network
{t('WgConfigurationDescription')}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
@@ -862,12 +855,10 @@ WantedBy=default.target`
<Alert variant="neutral">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
Save Your Credentials
{t('siteCredentialsSave')}
</AlertTitle>
<AlertDescription>
You will only be able to see this
once. Make sure to copy it to a
secure place.
{t('siteCredentialsSaveDescription')}
</AlertDescription>
</Alert>
@@ -902,8 +893,7 @@ WantedBy=default.target`
htmlFor="terms"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
I have copied
the config
{t('siteConfirmCopy')}
</label>
</div>
<FormMessage />
@@ -925,7 +915,7 @@ WantedBy=default.target`
router.push(`/${orgId}/settings/sites`);
}}
>
Cancel
{t('cancel')}
</Button>
<Button
type="button"
@@ -935,7 +925,7 @@ WantedBy=default.target`
form.handleSubmit(onSubmit)();
}}
>
Create Site
{t('siteCreate')}
</Button>
</div>
</div>

View File

@@ -5,6 +5,7 @@ import { AxiosResponse } from "axios";
import SitesTable, { SiteRow } from "./SitesTable";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import SitesSplashCard from "./SitesSplashCard";
import { getTranslations } from "next-intl/server";
type SitesPageProps = {
params: Promise<{ orgId: string }>;
@@ -23,16 +24,18 @@ export default async function SitesPage(props: SitesPageProps) {
sites = res.data.data.sites;
} catch (e) {}
const t = await getTranslations();
function formatSize(mb: number, type: string): string {
if (type === "local") {
return "-"; // because we are not able to track the data use in a local site right now
}
if (mb >= 1024 * 1024) {
return `${(mb / (1024 * 1024)).toFixed(2)} TB`;
return t('terabytes', {count: (mb / (1024 * 1024)).toFixed(2)});
} else if (mb >= 1024) {
return `${(mb / 1024).toFixed(2)} GB`;
return t('gigabytes', {count: (mb / 1024).toFixed(2)});
} else {
return `${mb.toFixed(2)} MB`;
return t('megabytes', {count: mb.toFixed(2)});
}
}
@@ -55,8 +58,8 @@ export default async function SitesPage(props: SitesPageProps) {
{/* <SitesSplashCard /> */}
<SettingsSectionTitle
title="Manage Sites"
description="Allow connectivity to your network through secure tunnels"
title={t('siteManageSites')}
description={t('siteDescription')}
/>
<SitesTable sites={siteRows} orgId={params.orgId} />