major ui tweaks and refactoring

This commit is contained in:
Milo Schwartz
2025-01-04 20:22:01 -05:00
parent 51bf5c1408
commit 64158a823b
91 changed files with 1791 additions and 1246 deletions

View File

@@ -15,6 +15,7 @@ import {
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableHeader,
TableRow,
@@ -88,7 +89,7 @@ export function RolesDataTable<TData, TValue>({
<Plus className="mr-2 h-4 w-4" /> Add Role
</Button>
</div>
<div className="border rounded-md">
<TableContainer>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
@@ -141,7 +142,7 @@ export function RolesDataTable<TData, TValue>({
)}
</TableBody>
</Table>
</div>
</TableContainer>
<div className="mt-4">
<DataTablePagination table={table} />
</div>

View File

@@ -67,7 +67,7 @@ export default function InviteUserForm({ open, setOpen }: InviteUserFormProps) {
const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]);
const [sendEmail, setSendEmail] = useState(env.EMAIL_ENABLED === "true");
const [sendEmail, setSendEmail] = useState(env.email.emailEnabled);
const validFor = [
{ hours: 24, name: "1 day" },
@@ -205,7 +205,7 @@ export default function InviteUserForm({ open, setOpen }: InviteUserFormProps) {
)}
/>
{env.EMAIL_ENABLED === "true" && (
{env.email.emailEnabled && (
<div className="flex items-center space-x-2">
<Checkbox
id="send-email"

View File

@@ -15,6 +15,7 @@ import {
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableHeader,
TableRow,
@@ -88,7 +89,7 @@ export function UsersDataTable<TData, TValue>({
<Plus className="mr-2 h-4 w-4" /> Invite User
</Button>
</div>
<div className="border rounded-md">
<TableContainer>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
@@ -141,7 +142,7 @@ export function UsersDataTable<TData, TValue>({
)}
</TableBody>
</Table>
</div>
</TableContainer>
<div className="mt-4">
<DataTablePagination table={table} />
</div>

View File

@@ -159,7 +159,7 @@ export default function UsersTable({ users: u }: UsersTableProps) {
const userRow = row.original;
return (
<div className="flex flex-row items-center gap-1">
<div className="flex flex-row items-center gap-2">
{userRow.isOwner && (
<Crown className="w-4 h-4 text-yellow-600" />
)}
@@ -186,7 +186,7 @@ export default function UsersTable({ users: u }: UsersTableProps) {
<Link
href={`/${org?.org.orgId}/settings/access/users/${userRow.id}`}
>
<Button variant={"gray"} className="ml-2">
<Button variant={"outline"} className="ml-2">
Manage
<ArrowRight className="ml-2 w-4 h-4" />
</Button>

View File

@@ -6,7 +6,7 @@ import {
FormField,
FormItem,
FormLabel,
FormMessage,
FormMessage
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
import {
@@ -14,7 +14,7 @@ import {
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
SelectValue
} from "@app/components/ui/select";
import { useToast } from "@app/hooks/useToast";
import { zodResolver } from "@hookform/resolvers/zod";
@@ -27,14 +27,23 @@ import { ListRolesResponse } from "@server/routers/role";
import { userOrgUserContext } from "@app/hooks/useOrgUserContext";
import { useParams } from "next/navigation";
import { Button } from "@app/components/ui/button";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { formatAxiosError } from "@app/lib/api";;
import {
SettingsContainer,
SettingsSection,
SettingsSectionHeader,
SettingsSectionTitle,
SettingsSectionDescription,
SettingsSectionBody,
SettingsSectionForm,
SettingsSectionFooter
} from "@app/components/Settings";
import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
const formSchema = z.object({
email: z.string().email({ message: "Please enter a valid email" }),
roleId: z.string().min(1, { message: "Please select a role" }),
roleId: z.string().min(1, { message: "Please select a role" })
});
export default function AccessControlsPage() {
@@ -52,8 +61,8 @@ export default function AccessControlsPage() {
resolver: zodResolver(formSchema),
defaultValues: {
email: user.email!,
roleId: user.roleId?.toString(),
},
roleId: user.roleId?.toString()
}
});
useEffect(() => {
@@ -68,7 +77,7 @@ export default function AccessControlsPage() {
description: formatAxiosError(
e,
"An error occurred while fetching the roles"
),
)
});
});
@@ -86,9 +95,9 @@ export default function AccessControlsPage() {
setLoading(true);
const res = await api
.post<AxiosResponse<InviteUserResponse>>(
`/role/${values.roleId}/add/${user.userId}`
)
.post<
AxiosResponse<InviteUserResponse>
>(`/role/${values.roleId}/add/${user.userId}`)
.catch((e) => {
toast({
variant: "destructive",
@@ -96,7 +105,7 @@ export default function AccessControlsPage() {
description: formatAxiosError(
e,
"An error occurred while adding user to the role."
),
)
});
});
@@ -104,7 +113,7 @@ export default function AccessControlsPage() {
toast({
variant: "default",
title: "User saved",
description: "The user has been updated.",
description: "The user has been updated."
});
}
@@ -112,59 +121,70 @@ export default function AccessControlsPage() {
}
return (
<>
<div className="space-y-8">
<SettingsSectionTitle
title="Access Controls"
description="Manage what this user can access and do in the organization"
size="1xl"
/>
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>Access Controls</SettingsSectionTitle>
<SettingsSectionDescription>
Manage what this user can access and do in the
organization
</SettingsSectionDescription>
</SettingsSectionHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
id="access-controls-form"
>
<FormField
control={form.control}
name="roleId"
render={({ field }) => (
<FormItem>
<FormLabel>Role</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select role" />
</SelectTrigger>
</FormControl>
<SelectContent>
{roles.map((role) => (
<SelectItem
key={role.roleId}
value={role.roleId.toString()}
>
{role.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
type="submit"
loading={loading}
disabled={loading}
form="access-controls-form"
>
<FormField
control={form.control}
name="roleId"
render={({ field }) => (
<FormItem>
<FormLabel>Role</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select role" />
</SelectTrigger>
</FormControl>
<SelectContent>
{roles.map((role) => (
<SelectItem
key={role.roleId}
value={role.roleId.toString()}
>
{role.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
loading={loading}
disabled={loading}
>
Save Changes
</Button>
</form>
</Form>
</div>
</>
Save Access Controls
</Button>
</SettingsSectionFooter>
</SettingsSection>
</SettingsContainer>
);
}

View File

@@ -21,7 +21,7 @@ import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { formatAxiosError } from "@app/lib/api";;
import { formatAxiosError } from "@app/lib/api";
import { AlertTriangle, Trash2 } from "lucide-react";
import {
Card,
@@ -33,6 +33,16 @@ import {
import { AxiosResponse } from "axios";
import { DeleteOrgResponse, ListOrgsResponse } from "@server/routers/org";
import { redirect, useRouter } from "next/navigation";
import {
SettingsContainer,
SettingsSection,
SettingsSectionHeader,
SettingsSectionTitle,
SettingsSectionDescription,
SettingsSectionBody,
SettingsSectionForm,
SettingsSectionFooter
} from "@app/components/Settings";
const GeneralFormSchema = z.object({
name: z.string()
@@ -80,10 +90,7 @@ export default function GeneralPage() {
async function pickNewOrgAndNavigate() {
try {
const res = await api.get<AxiosResponse<ListOrgsResponse>>(
`/orgs`
);
const res = await api.get<AxiosResponse<ListOrgsResponse>>(`/orgs`);
if (res.status === 200) {
if (res.data.data.orgs.length > 0) {
@@ -126,7 +133,7 @@ export default function GeneralPage() {
}
return (
<>
<SettingsContainer>
<ConfirmDeleteDialog
open={isDeleteModalOpen}
setOpen={(val) => {
@@ -138,12 +145,10 @@ export default function GeneralPage() {
Are you sure you want to delete the organization{" "}
<b>{org?.org.name}?</b>
</p>
<p className="mb-2">
This action is irreversible and will delete all
associated data.
</p>
<p>
To confirm, type the name of the organization below.
</p>
@@ -155,57 +160,75 @@ export default function GeneralPage() {
title="Delete Organization"
/>
<section className="space-y-8 max-w-lg">
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
This is the display name of the org
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">Save Changes</Button>
</form>
</Form>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
Organization Settings
</SettingsSectionTitle>
<SettingsSectionDescription>
Manage your organization details and configuration
</SettingsSectionDescription>
</SettingsSectionHeader>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-red-600">
<AlertTriangle className="h-5 w-5" />
Danger Zone
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm">
Once you delete this org, there is no going back.
Please be certain.
</p>
</CardContent>
<CardFooter className="flex justify-end gap-2">
<Button
variant="destructive"
onClick={() => setIsDeleteModalOpen(true)}
className="flex items-center gap-2"
>
<Trash2 className="h-4 w-4" />
Delete Organization Data
</Button>
</CardFooter>
</Card>
</section>
</>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
id="org-settings-form"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
This is the display name of the
org
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button type="submit" form="org-settings-form">
Save Settings
</Button>
</SettingsSectionFooter>
</SettingsSection>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
<AlertTriangle className="h-5 w-5" />
Danger Zone
</SettingsSectionTitle>
<SettingsSectionDescription>
Once you delete this org, there is no going back. Please
be certain.
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionFooter>
<Button
variant="destructive"
onClick={() => setIsDeleteModalOpen(true)}
className="flex items-center gap-2"
>
Delete Organization Data
</Button>
</SettingsSectionFooter>
</SettingsSection>
</SettingsContainer>
);
}

View File

@@ -36,7 +36,7 @@ const topNavItems = [
icon: <Users className="h-4 w-4" />
},
{
title: "Sharable Links",
title: "Shareable Links",
href: "/{orgId}/settings/share-links",
icon: <Link className="h-4 w-4" />
},
@@ -95,7 +95,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
return (
<>
<div className="w-full border-b bg-neutral-100 dark:bg-neutral-800 select-none sm:px-0 px-3 fixed top-0 z-10">
<div className="w-full border-b bg-card select-none sm:px-0 px-3 fixed top-0 z-10">
<div className="container mx-auto flex flex-col content-between">
<div className="my-4">
<UserProvider user={user}>

View File

@@ -6,7 +6,7 @@ type OrgPageProps = {
export default async function SettingsPage(props: OrgPageProps) {
const params = await props.params;
redirect(`/${params.orgId}/settings/resources`);
redirect(`/${params.orgId}/settings/sites`);
return <></>;
}

View File

@@ -16,6 +16,7 @@ import {
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableHeader,
TableRow,
@@ -89,7 +90,7 @@ export function ResourcesDataTable<TData, TValue>({
<Plus className="mr-2 h-4 w-4" /> Add Resource
</Button>
</div>
<div className="border rounded-md">
<TableContainer>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
@@ -141,7 +142,7 @@ export function ResourcesDataTable<TData, TValue>({
)}
</TableBody>
</Table>
</div>
</TableContainer>
<div className="mt-4">
<DataTablePagination table={table} />
</div>

View File

@@ -0,0 +1,68 @@
"use client";
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";
export const ResourcesSplashCard = () => {
const [isDismissed, setIsDismissed] = useState(false);
const key = "resources-splash-dismissed";
useEffect(() => {
const dismissed = localStorage.getItem(key);
if (dismissed === "true") {
setIsDismissed(true);
}
}, []);
const handleDismiss = () => {
setIsDismissed(true);
localStorage.setItem(key, "true");
};
if (isDismissed) {
return null;
}
return (
<Card className="w-full mx-auto overflow-hidden mb-8 hidden md:block relative">
<button
onClick={handleDismiss}
className="absolute top-2 right-2 p-2"
aria-label="Dismiss"
>
<X className="w-5 h-5" />
</button>
<CardContent className="grid gap-6 p-6">
<div className="space-y-4">
<h3 className="text-xl font-semibold flex items-center gap-2">
<Server className="text-blue-500" />
Resources
</h3>
<p className="text-sm">
Resources are proxies to applications running on your private network. Create a resource for any HTTP or HTTPS app on your private network.
Each resource must be connected to a site to enable private, secure connectivity through an encrypted WireGuard tunnel.
</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
</li>
<li className="flex items-center gap-2">
<Key className="text-yellow-500 w-4 h-4" />
Configure multiple authentication methods
</li>
<li className="flex items-center gap-2">
<Users className="text-purple-500 w-4 h-4" />
User and role-based access control
</li>
</ul>
</div>
</CardContent>
</Card>
);
};
export default ResourcesSplashCard;

View File

@@ -210,7 +210,7 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
<Link
href={`/${resourceRow.orgId}/settings/resources/${resourceRow.id}`}
>
<Button variant={"gray"} className="ml-2">
<Button variant={"outline"} className="ml-2">
Edit
<ArrowRight className="ml-2 w-4 h-4" />
</Button>

View File

@@ -60,9 +60,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
<div className="flex items-center space-x-2 text-yellow-500">
<ShieldOff className="w-4 h-4" />
<span>
This resource is not protected with any
auth method. Anyone can access this
resource.
Anyone can access this resource.
</span>
</div>
)}

View File

@@ -28,16 +28,26 @@ import {
FormMessage
} from "@app/components/ui/form";
import { TagInput } from "emblor";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
// import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { ListUsersResponse } from "@server/routers/user";
import { Switch } from "@app/components/ui/switch";
import { Label } from "@app/components/ui/label";
import { Binary, Key, ShieldCheck } from "lucide-react";
import SetResourcePasswordForm from "./SetResourcePasswordForm";
import { Separator } from "@app/components/ui/separator";
import SetResourcePincodeForm from "./SetResourcePincodeForm";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import {
SettingsContainer,
SettingsSection,
SettingsSectionTitle,
SettingsSectionHeader,
SettingsSectionDescription,
SettingsSectionBody,
SettingsSectionForm,
SettingsSectionFooter
} from "@app/components/Settings";
import { SwitchInput } from "@app/components/SwitchInput";
const UsersRolesFormSchema = z.object({
roles: z.array(
@@ -382,328 +392,80 @@ export default function ResourceAuthenticationPage() {
/>
)}
<div className="space-y-12">
<section className="space-y-4 lg:max-w-2xl">
<SettingsSectionTitle
title="Users & Roles"
description="Configure which users and roles can visit this resource"
size="1xl"
/>
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
Users & Roles
</SettingsSectionTitle>
<SettingsSectionDescription>
Configure which users and roles can visit this
resource
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SwitchInput
id="sso-toggle"
label="Use Platform SSO"
description="Existing users will only have to login once for all resources that have this enabled."
defaultChecked={resource.sso}
onCheckedChange={(val) => setSsoEnabled(val)}
/>
<div>
<div className="flex items-center space-x-2 mb-2">
<Switch
id="sso-toggle"
defaultChecked={resource.sso}
onCheckedChange={(val) => setSsoEnabled(val)}
/>
<Label htmlFor="sso-toggle">Use Platform SSO</Label>
</div>
<span className="text-muted-foreground text-sm">
Existing users will only have to login once for all
resources that have this enabled.
</span>
</div>
<Form {...usersRolesForm}>
<form
onSubmit={usersRolesForm.handleSubmit(
onSubmitUsersRoles
)}
className="space-y-4"
>
{ssoEnabled && (
<>
<FormField
control={usersRolesForm.control}
name="roles"
render={({ field }) => (
<FormItem className="flex flex-col items-start">
<FormLabel>Roles</FormLabel>
<FormControl>
{/* @ts-ignore */}
<TagInput
{...field}
activeTagIndex={
activeRolesTagIndex
}
setActiveTagIndex={
setActiveRolesTagIndex
}
placeholder="Enter a role"
tags={
usersRolesForm.getValues()
.roles
}
setTags={(newRoles) => {
usersRolesForm.setValue(
"roles",
newRoles as [
Tag,
...Tag[]
]
);
}}
enableAutocomplete={
true
}
autocompleteOptions={
allRoles
}
allowDuplicates={false}
restrictTagsToAutocompleteOptions={
true
}
sortTags={true}
styleClasses={{
tag: {
body: "bg-muted hover:bg-accent text-foreground py-2 px-3 rounded-full"
},
input: "text-base md:text-sm border-none bg-transparent text-inherit placeholder:text-inherit shadow-none",
inlineTagsContainer:
"bg-transparent p-2"
}}
/>
</FormControl>
<FormDescription>
These roles will be able to
access this resource. Admins
can always access this
resource.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={usersRolesForm.control}
name="users"
render={({ field }) => (
<FormItem className="flex flex-col items-start">
<FormLabel>Users</FormLabel>
<FormControl>
{/* @ts-ignore */}
<TagInput
{...field}
activeTagIndex={
activeUsersTagIndex
}
setActiveTagIndex={
setActiveUsersTagIndex
}
placeholder="Enter a user"
tags={
usersRolesForm.getValues()
.users
}
setTags={(newUsers) => {
usersRolesForm.setValue(
"users",
newUsers as [
Tag,
...Tag[]
]
);
}}
enableAutocomplete={
true
}
autocompleteOptions={
allUsers
}
allowDuplicates={false}
restrictTagsToAutocompleteOptions={
true
}
sortTags={true}
styleClasses={{
tag: {
body: "bg-muted hover:bg-accent text-foreground py-2 px-3 rounded-full"
},
input: "text-base md:text-sm border-none bg-transparent text-inherit placeholder:text-inherit shadow-none",
inlineTagsContainer:
"bg-transparent p-2"
}}
/>
</FormControl>
<FormDescription>
Users added here will be
able to access this
resource. A user will always
have access to a resource if
they have a role that has
access to it.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
<Button
type="submit"
loading={loadingSaveUsersRoles}
disabled={loadingSaveUsersRoles}
<Form {...usersRolesForm}>
<form
onSubmit={usersRolesForm.handleSubmit(
onSubmitUsersRoles
)}
id="users-roles-form"
className="space-y-4"
>
Save Users & Roles
</Button>
</form>
</Form>
</section>
<Separator />
<section className="space-y-4 lg:max-w-2xl">
<SettingsSectionTitle
title="Authentication Methods"
description="Allow access to the resource via additional auth methods"
size="1xl"
/>
<div className="flex flex-col space-y-4">
<div className="flex items-center justify-between space-x-4">
<div
className={`flex items-center text-${!authInfo.password ? "red" : "green"}-500 space-x-2`}
>
<Key />
<span>
Password Protection{" "}
{authInfo?.password
? "Enabled"
: "Disabled"}
</span>
</div>
{authInfo?.password ? (
<Button
variant="gray"
type="button"
loading={loadingRemoveResourcePassword}
disabled={loadingRemoveResourcePassword}
onClick={removeResourcePassword}
>
Remove Password
</Button>
) : (
<Button
variant="gray"
type="button"
onClick={() => setIsSetPasswordOpen(true)}
>
Add Password
</Button>
)}
</div>
<div className="flex items-center justify-between space-x-4">
<div
className={`flex items-center text-${!authInfo.pincode ? "red" : "green"}-500 space-x-2`}
>
<Binary />
<span>
PIN Code Protection{" "}
{authInfo?.pincode ? "Enabled" : "Disabled"}
</span>
</div>
{authInfo?.pincode ? (
<Button
variant="gray"
type="button"
loading={loadingRemoveResourcePincode}
disabled={loadingRemoveResourcePincode}
onClick={removeResourcePincode}
>
Remove PIN Code
</Button>
) : (
<Button
variant="gray"
type="button"
onClick={() => setIsSetPincodeOpen(true)}
>
Add PIN Code
</Button>
)}
</div>
</div>
</section>
<Separator />
<section className="space-y-4 lg:max-w-2xl">
{env.EMAIL_ENABLED === "true" && (
<>
<div>
<div className="flex items-center space-x-2 mb-2">
<Switch
id="whitelist-toggle"
defaultChecked={
resource.emailWhitelistEnabled
}
onCheckedChange={(val) =>
setWhitelistEnabled(val)
}
/>
<Label htmlFor="whitelist-toggle">
Email Whitelist
</Label>
</div>
<span className="text-muted-foreground text-sm">
Enable resource whitelist to require
email-based authentication (one-time
passwords) for resource access.
</span>
</div>
{whitelistEnabled && (
<Form {...whitelistForm}>
<form className="space-y-4">
{ssoEnabled && (
<>
<FormField
control={whitelistForm.control}
name="emails"
control={usersRolesForm.control}
name="roles"
render={({ field }) => (
<FormItem className="flex flex-col items-start">
<FormLabel>
Whitelisted Emails
</FormLabel>
<FormLabel>Roles</FormLabel>
<FormControl>
{/* @ts-ignore */}
<TagInput
{...field}
activeTagIndex={
activeEmailTagIndex
activeRolesTagIndex
}
validateTag={(
tag
) => {
return z
.string()
.email()
.safeParse(
tag
).success;
}}
setActiveTagIndex={
setActiveEmailTagIndex
setActiveRolesTagIndex
}
placeholder="Enter an email"
placeholder="Enter a role"
tags={
whitelistForm.getValues()
.emails
usersRolesForm.getValues()
.roles
}
setTags={(
newRoles
) => {
whitelistForm.setValue(
"emails",
usersRolesForm.setValue(
"roles",
newRoles as [
Tag,
...Tag[]
]
);
}}
enableAutocomplete={
true
}
autocompleteOptions={
allRoles
}
allowDuplicates={
false
}
restrictTagsToAutocompleteOptions={
true
}
sortTags={true}
styleClasses={{
tag: {
@@ -715,24 +477,271 @@ export default function ResourceAuthenticationPage() {
}}
/>
</FormControl>
<FormDescription>
These roles will be able
to access this resource.
Admins can always access
this resource.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
)}
<FormField
control={usersRolesForm.control}
name="users"
render={({ field }) => (
<FormItem className="flex flex-col items-start">
<FormLabel>Users</FormLabel>
<FormControl>
{/* @ts-ignore */}
<TagInput
{...field}
activeTagIndex={
activeUsersTagIndex
}
setActiveTagIndex={
setActiveUsersTagIndex
}
placeholder="Enter a user"
tags={
usersRolesForm.getValues()
.users
}
setTags={(
newUsers
) => {
usersRolesForm.setValue(
"users",
newUsers as [
Tag,
...Tag[]
]
);
}}
enableAutocomplete={
true
}
autocompleteOptions={
allUsers
}
allowDuplicates={
false
}
restrictTagsToAutocompleteOptions={
true
}
sortTags={true}
styleClasses={{
tag: {
body: "bg-muted hover:bg-accent text-foreground py-2 px-3 rounded-full"
},
input: "text-base md:text-sm border-none bg-transparent text-inherit placeholder:text-inherit shadow-none",
inlineTagsContainer:
"bg-transparent p-2"
}}
/>
</FormControl>
<FormDescription>
Users added here will be
able to access this
resource. A user will
always have access to a
resource if they have a
role that has access to
it.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
</form>
</Form>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
type="submit"
loading={loadingSaveUsersRoles}
disabled={loadingSaveUsersRoles}
form="users-roles-form"
>
Save Users & Roles
</Button>
</SettingsSectionFooter>
</SettingsSection>
<Button
loading={loadingSaveWhitelist}
disabled={loadingSaveWhitelist}
onClick={saveWhitelist}
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
Authentication Methods
</SettingsSectionTitle>
<SettingsSectionDescription>
Allow access to the resource via additional auth
methods
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
{/* Password Protection */}
<div className="flex items-center justify-between">
<div
className={`flex items-center text-${!authInfo.password ? "neutral" : "green"}-500 space-x-2`}
>
Save Whitelist
<Key />
<span>
Password Protection{" "}
{authInfo.password ? "Enabled" : "Disabled"}
</span>
</div>
<Button
variant="outline"
onClick={
authInfo.password
? removeResourcePassword
: () => setIsSetPasswordOpen(true)
}
loading={loadingRemoveResourcePassword}
>
{authInfo.password
? "Remove Password"
: "Add Password"}
</Button>
</>
)}
</section>
</div>
</div>
{/* PIN Code Protection */}
<div className="flex items-center justify-between">
<div
className={`flex items-center text-${!authInfo.pincode ? "neutral" : "green"}-500 space-x-2`}
>
<Binary />
<span>
PIN Code Protection{" "}
{authInfo.pincode ? "Enabled" : "Disabled"}
</span>
</div>
<Button
variant="outline"
onClick={
authInfo.pincode
? removeResourcePincode
: () => setIsSetPincodeOpen(true)
}
loading={loadingRemoveResourcePincode}
>
{authInfo.pincode
? "Remove PIN Code"
: "Add PIN Code"}
</Button>
</div>
</SettingsSectionBody>
</SettingsSection>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
One-time Passwords
</SettingsSectionTitle>
<SettingsSectionDescription>
Require email-based authentication for resource
access
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
{env.email.emailEnabled && (
<>
<SwitchInput
id="whitelist-toggle"
label="Email Whitelist"
defaultChecked={
resource.emailWhitelistEnabled
}
onCheckedChange={setWhitelistEnabled}
/>
{whitelistEnabled && (
<Form {...whitelistForm}>
<form id="whitelist-form">
<FormField
control={whitelistForm.control}
name="emails"
render={({ field }) => (
<FormItem>
<FormLabel>
Whitelisted Emails
</FormLabel>
<FormControl>
{/* @ts-ignore */}
{/* @ts-ignore */}
<TagInput
{...field}
activeTagIndex={
activeEmailTagIndex
}
validateTag={(
tag
) => {
return z
.string()
.email()
.safeParse(
tag
)
.success;
}}
setActiveTagIndex={
setActiveEmailTagIndex
}
placeholder="Enter an email"
tags={
whitelistForm.getValues()
.emails
}
setTags={(
newRoles
) => {
whitelistForm.setValue(
"emails",
newRoles as [
Tag,
...Tag[]
]
);
}}
allowDuplicates={
false
}
sortTags={true}
styleClasses={{
tag: {
body: "bg-muted hover:bg-accent text-foreground py-2 px-3 rounded-full"
},
input: "text-base md:text-sm border-none bg-transparent text-inherit placeholder:text-inherit shadow-none",
inlineTagsContainer:
"bg-transparent p-2"
}}
/>
</FormControl>
</FormItem>
)}
/>
</form>
</Form>
)}
</>
)}
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
onClick={saveWhitelist}
form="whitelist-form"
loading={loadingSaveWhitelist}
>
Save Whitelist
</Button>
</SettingsSectionFooter>
</SettingsSection>
</SettingsContainer>
</>
);
}

View File

@@ -40,28 +40,34 @@ import {
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableHeader,
TableRow
} from "@app/components/ui/table";
import { useToast } from "@app/hooks/useToast";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { useResourceContext } from "@app/hooks/useResourceContext";
import { ArrayElement } from "@server/types/ArrayElement";
import { formatAxiosError } from "@app/lib/api/formatAxiosError";;
import { formatAxiosError } from "@app/lib/api/formatAxiosError";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { createApiClient } from "@app/lib/api";
import { GetSiteResponse } from "@server/routers/site";
import {
SettingsContainer,
SettingsSection,
SettingsSectionHeader,
SettingsSectionTitle,
SettingsSectionDescription,
SettingsSectionBody,
SettingsSectionForm,
SettingsSectionFooter
} from "@app/components/Settings";
import { SwitchInput } from "@app/components/SwitchInput";
const addTargetSchema = z.object({
ip: z.string().ip(),
method: z.string(),
port: z
.string()
.refine((val) => !isNaN(Number(val)), {
message: "Port must be a number"
})
.transform((val) => Number(val))
port: z.coerce.number().int().positive()
// protocol: z.string(),
});
@@ -99,7 +105,7 @@ export default function ReverseProxyTargets(props: {
defaultValues: {
ip: "",
method: "http",
port: "80"
port: 80
// protocol: "TCP",
}
});
@@ -154,7 +160,7 @@ export default function ReverseProxyTargets(props: {
fetchSite();
}, []);
async function addTarget(data: AddTargetFormValues) {
async function addTarget(data: z.infer<typeof addTargetSchema>) {
// Check if target with same IP, port and method already exists
const isDuplicate = targets.some(
(target) =>
@@ -218,16 +224,10 @@ export default function ReverseProxyTargets(props: {
);
}
async function saveAll() {
async function saveTargets() {
try {
setLoading(true);
const res = await api.post(`/resource/${params.resourceId}`, {
ssl: sslEnabled
});
updateResource({ ssl: sslEnabled });
for (let target of targets) {
const data = {
ip: target.ip,
@@ -269,8 +269,8 @@ export default function ReverseProxyTargets(props: {
}
toast({
title: "Resource updated",
description: "Resource and targets updated successfully"
title: "Targets updated",
description: "Targets updated successfully"
});
setTargetsToRemove([]);
@@ -289,6 +289,20 @@ export default function ReverseProxyTargets(props: {
setLoading(false);
}
async function saveSsl(val: boolean) {
const res = await api.post(`/resource/${params.resourceId}`, {
ssl: val
});
setSslEnabled(val);
updateResource({ ssl: sslEnabled });
toast({
title: "SSL Configuration",
description: "SSL configuration updated successfully"
});
}
const columns: ColumnDef<LocalTarget>[] = [
{
accessorKey: "method",
@@ -410,239 +424,180 @@ export default function ReverseProxyTargets(props: {
}
return (
<>
<div className="space-y-12">
<section className="space-y-4">
<SettingsSectionTitle
title="SSL"
description="Setup SSL to secure your connections with LetsEncrypt certificates"
size="1xl"
<SettingsContainer>
{/* SSL Section */}
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
SSL Configuration
</SettingsSectionTitle>
<SettingsSectionDescription>
Setup SSL to secure your connections with LetsEncrypt
certificates
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SwitchInput
id="ssl-toggle"
label="Enable SSL (https)"
defaultChecked={resource.ssl}
onCheckedChange={async (val) => {
await saveSsl(val);
}}
/>
</SettingsSectionBody>
</SettingsSection>
<div className="flex items-center space-x-2">
<Switch
id="ssl-toggle"
defaultChecked={resource.ssl}
onCheckedChange={(val) => setSslEnabled(val)}
/>
<Label htmlFor="ssl-toggle">Enable SSL (https)</Label>
</div>
</section>
<hr />
<section className="space-y-4">
<SettingsSectionTitle
title="Targets"
description="Setup targets to route traffic to your services"
size="1xl"
/>
<div className="space-y-4">
<Form {...addTargetForm}>
<form
onSubmit={addTargetForm.handleSubmit(
addTarget as any
)}
className="space-y-4"
>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
<FormField
control={addTargetForm.control}
name="method"
render={({ field }) => (
<FormItem>
<FormLabel>Method</FormLabel>
<FormControl>
<Select
{...field}
onValueChange={(
value
) => {
addTargetForm.setValue(
"method",
value
);
}}
>
<SelectTrigger id="method">
<SelectValue placeholder="Select method" />
</SelectTrigger>
<SelectContent>
<SelectItem value="http">
http
</SelectItem>
<SelectItem value="https">
https
</SelectItem>
</SelectContent>
</Select>
</FormControl>
{/* <FormDescription> */}
{/* Choose the method for how */}
{/* the target is accessed. */}
{/* </FormDescription> */}
<FormMessage />
</FormItem>
)}
/>
<FormField
control={addTargetForm.control}
name="ip"
render={({ field }) => (
<FormItem>
<FormLabel>
IP Address
</FormLabel>
<FormControl>
<Input id="ip" {...field} />
</FormControl>
{/* <FormDescription> */}
{/* Use the IP of the resource on your private network if using Newt, or the peer IP if using raw WireGuard. */}
{/* </FormDescription> */}
<FormMessage />
</FormItem>
)}
/>
<FormField
control={addTargetForm.control}
name="port"
render={({ field }) => (
<FormItem>
<FormLabel>Port</FormLabel>
<FormControl>
<Input
id="port"
type="number"
{...field}
required
/>
</FormControl>
{/* <FormDescription> */}
{/* Specify the port number for */}
{/* the target. */}
{/* </FormDescription> */}
<FormMessage />
</FormItem>
)}
/>
{/* <FormField
{/* Targets Section */}
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
Target Configuration
</SettingsSectionTitle>
<SettingsSectionDescription>
Setup targets to route traffic to your services
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<Form {...addTargetForm}>
<form
onSubmit={addTargetForm.handleSubmit(addTarget)}
className="space-y-4"
>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
<FormField
control={addTargetForm.control}
name="protocol"
name="method"
render={({ field }) => (
<FormItem>
<FormLabel>Protocol</FormLabel>
<FormLabel>Method</FormLabel>
<FormControl>
<Select
{...field}
onValueChange={(value) => {
addTargetForm.setValue(
"protocol",
"method",
value
);
}}
>
<SelectTrigger id="protocol">
<SelectValue placeholder="Select protocol" />
<SelectTrigger id="method">
<SelectValue placeholder="Select method" />
</SelectTrigger>
<SelectContent>
<SelectItem value="UDP">
UDP
<SelectItem value="http">
http
</SelectItem>
<SelectItem value="TCP">
TCP
<SelectItem value="https">
https
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormDescription>
Select the protocol used by the
target
</FormDescription>
<FormMessage />
</FormItem>
)}
/> */}
</div>
<Button type="submit" variant="gray">
Add Target
</Button>
</form>
</Form>
<div className="rounded-md border">
<Table>
<TableHeader>
{table
.getHeaderGroups()
.map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map(
(header) => (
<TableHead
key={header.id}
>
{header.isPlaceholder
? null
: flexRender(
header
.column
.columnDef
.header,
header.getContext()
)}
</TableHead>
)
)}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row
.getVisibleCells()
.map((cell) => (
<TableCell
key={cell.id}
>
{flexRender(
cell.column
.columnDef
.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
No targets. Add a target using
the form.
</TableCell>
</TableRow>
/>
<FormField
control={addTargetForm.control}
name="ip"
render={({ field }) => (
<FormItem>
<FormLabel>IP Address</FormLabel>
<FormControl>
<Input id="ip" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
</TableBody>
</Table>
</div>
/>
<FormField
control={addTargetForm.control}
name="port"
render={({ field }) => (
<FormItem>
<FormLabel>Port</FormLabel>
<FormControl>
<Input
id="port"
type="number"
{...field}
required
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<Button type="submit" variant="outline">
Add Target
</Button>
</form>
</Form>
<Button
onClick={saveAll}
loading={loading}
disabled={loading}
>
Save Changes
</Button>
</div>
</section>
</div>
</>
<TableContainer>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column
.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row
.getVisibleCells()
.map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column
.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
No targets. Add a target using the
form.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
onClick={saveTargets}
loading={loading}
disabled={loading}
>
Save Targets
</Button>
</SettingsSectionFooter>
</SettingsSection>
</SettingsContainer>
);
}

View File

@@ -11,7 +11,7 @@ import {
FormField,
FormItem,
FormLabel,
FormMessage,
FormMessage
} from "@/components/ui/form";
import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons";
import { Input } from "@/components/ui/input";
@@ -21,13 +21,13 @@ import {
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandList
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
PopoverTrigger
} from "@/components/ui/popover";
import { useResourceContext } from "@app/hooks/useResourceContext";
import { ListSitesResponse } from "@server/routers/site";
@@ -37,7 +37,16 @@ import { useParams, useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { GetResourceAuthInfoResponse } from "@server/routers/resource";
import { useToast } from "@app/hooks/useToast";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import {
SettingsContainer,
SettingsSection,
SettingsSectionHeader,
SettingsSectionTitle,
SettingsSectionDescription,
SettingsSectionBody,
SettingsSectionForm,
SettingsSectionFooter
} from "@app/components/Settings";
import { useOrgContext } from "@app/hooks/useOrgContext";
import CustomDomainInput from "../CustomDomainInput";
import ResourceInfoBox from "../ResourceInfoBox";
@@ -47,7 +56,7 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
const GeneralFormSchema = z.object({
name: z.string(),
subdomain: subdomainSchema,
subdomain: subdomainSchema
// siteId: z.number(),
});
@@ -72,10 +81,10 @@ export default function GeneralForm() {
resolver: zodResolver(GeneralFormSchema),
defaultValues: {
name: resource.name,
subdomain: resource.subdomain,
subdomain: resource.subdomain
// siteId: resource.siteId!,
},
mode: "onChange",
mode: "onChange"
});
useEffect(() => {
@@ -95,7 +104,7 @@ export default function GeneralForm() {
`resource/${resource?.resourceId}`,
{
name: data.name,
subdomain: data.subdomain,
subdomain: data.subdomain
// siteId: data.siteId,
}
)
@@ -106,13 +115,13 @@ export default function GeneralForm() {
description: formatAxiosError(
e,
"An error occurred while updating the resource"
),
)
});
})
.then(() => {
toast({
title: "Resource updated",
description: "The resource has been updated successfully",
description: "The resource has been updated successfully"
});
updateResource({ name: data.name, subdomain: data.subdomain });
@@ -123,153 +132,85 @@ export default function GeneralForm() {
}
return (
<>
<div className="space-y-12 lg:max-w-2xl">
<section className="space-y-4">
<SettingsSectionTitle
title="General Settings"
description="Configure the general settings for this resource"
size="1xl"
/>
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
General Settings
</SettingsSectionTitle>
<SettingsSectionDescription>
Configure the general settings for this resource
</SettingsSectionDescription>
</SettingsSectionHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
This is the display name of the
resource.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="subdomain"
render={({ field }) => (
<FormItem>
<FormLabel>Subdomain</FormLabel>
<FormControl>
<CustomDomainInput
value={field.value}
domainSuffix={domainSuffix}
placeholder="Enter subdomain"
onChange={(value) =>
form.setValue(
"subdomain",
value
)
}
/>
</FormControl>
<FormDescription>
This is the subdomain that will be
used to access the resource.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* <FormField
control={form.control}
name="siteId"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>Site</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
role="combobox"
className={cn(
"w-[350px] justify-between",
!field.value &&
"text-muted-foreground"
)}
>
{field.value
? sites.find(
(site) =>
site.siteId ===
field.value
)?.name
: "Select site"}
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-[350px] p-0">
<Command>
<CommandInput placeholder="Search sites" />
<CommandList>
<CommandEmpty>
No sites found.
</CommandEmpty>
<CommandGroup>
{sites.map((site) => (
<CommandItem
value={
site.name
}
key={
site.siteId
}
onSelect={() => {
form.setValue(
"siteId",
site.siteId
);
}}
>
<CheckIcon
className={cn(
"mr-2 h-4 w-4",
site.siteId ===
field.value
? "opacity-100"
: "opacity-0"
)}
/>
{site.name}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<FormDescription>
This is the site that will be used in
the dashboard.
</FormDescription>
<FormMessage />
</FormItem>
)}
/> */}
<Button
type="submit"
loading={saveLoading}
disabled={saveLoading}
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
>
Save Changes
</Button>
</form>
</Form>
</section>
</div>
</>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
This is the display name of the
resource.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="subdomain"
render={({ field }) => (
<FormItem>
<FormLabel>Subdomain</FormLabel>
<FormControl>
<CustomDomainInput
value={field.value}
domainSuffix={domainSuffix}
placeholder="Enter subdomain"
onChange={(value) =>
form.setValue(
"subdomain",
value
)
}
/>
</FormControl>
<FormDescription>
This is the subdomain that will
be used to access the resource.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
type="submit"
loading={saveLoading}
disabled={saveLoading}
form="general-settings-form"
>
Save Settings
</Button>
</SettingsSectionFooter>
</SettingsSection>
</SettingsContainer>
);
}

View File

@@ -8,6 +8,7 @@ import { redirect } from "next/navigation";
import { cache } from "react";
import { GetOrgResponse } from "@server/routers/org";
import OrgProvider from "@app/providers/OrgProvider";
import ResourcesSplashCard from "./ResourcesSplashCard";
type ResourcesPageProps = {
params: Promise<{ orgId: string }>;
@@ -62,6 +63,8 @@ export default async function ResourcesPage(props: ResourcesPageProps) {
return (
<>
<ResourcesSplashCard />
<SettingsSectionTitle
title="Manage Resources"
description="Create secure proxies to your private applications"

View File

@@ -16,6 +16,7 @@ import {
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableHeader,
TableRow
@@ -89,7 +90,7 @@ export function ShareLinksDataTable<TData, TValue>({
<Plus className="mr-2 h-4 w-4" /> Create Share Link
</Button>
</div>
<div className="border rounded-md">
<TableContainer>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
@@ -141,7 +142,7 @@ export function ShareLinksDataTable<TData, TValue>({
)}
</TableBody>
</Table>
</div>
</TableContainer>
<div className="mt-4">
<DataTablePagination table={table} />
</div>

View File

@@ -0,0 +1,70 @@
"use client";
import React, { useState, useEffect } from "react";
import { Link, X, Clock, Share, ArrowRight, Lock } from "lucide-react"; // Replace with actual imports
import { Card, CardContent } from "@app/components/ui/card";
import { Button } from "@app/components/ui/button";
export const ShareableLinksSplash = () => {
const [isDismissed, setIsDismissed] = useState(false);
const key = "share-links-splash-dismissed";
useEffect(() => {
const dismissed = localStorage.getItem(key);
if (dismissed === "true") {
setIsDismissed(true);
}
}, []);
const handleDismiss = () => {
setIsDismissed(true);
localStorage.setItem(key, "true");
};
if (isDismissed) {
return null;
}
return (
<Card className="w-full mx-auto overflow-hidden mb-8 hidden md:block relative">
<button
onClick={handleDismiss}
className="absolute top-2 right-2 p-2"
aria-label="Dismiss"
>
<X className="w-5 h-5" />
</button>
<CardContent className="grid gap-6 p-6">
<div className="space-y-4">
<h3 className="text-xl font-semibold flex items-center gap-2">
<Link className="text-blue-500" />
Shareable Links
</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.
</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
</li>
<li className="flex items-center gap-2">
<Clock className="text-yellow-500 w-4 h-4" />
Configurable expiration duration
</li>
<li className="flex items-center gap-2">
<Lock className="text-red-500 w-4 h-4" />
Secure and revocable
</li>
</ul>
</div>
</CardContent>
</Card>
);
};
export default ShareableLinksSplash;

View File

@@ -8,6 +8,7 @@ import { GetOrgResponse } from "@server/routers/org";
import OrgProvider from "@app/providers/OrgProvider";
import { ListAccessTokensResponse } from "@server/routers/accessToken";
import ShareLinksTable, { ShareLinkRow } from "./ShareLinksTable";
import ShareableLinksSplash from "./ShareLinksSplash";
type ShareLinksPageProps = {
params: Promise<{ orgId: string }>;
@@ -52,6 +53,8 @@ export default async function ShareLinksPage(props: ShareLinksPageProps) {
return (
<>
<ShareableLinksSplash />
<SettingsSectionTitle
title="Manage Share Links"
description="Create shareable links to grant temporary or permanent access to your resources"

View File

@@ -36,6 +36,9 @@ import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { SiteRow } from "./SitesTable";
import { AxiosResponse } from "axios";
import { Button } from "@app/components/ui/button";
import Link from "next/link";
import { ArrowUpRight } from "lucide-react";
const createSiteFormSchema = z.object({
name: z
@@ -274,6 +277,24 @@ PersistentKeepalive = 5`
You will only be able to see the configuration once.
</span>
{form.watch("method") === "newt" && (
<>
<br />
<Link
className="text-sm text-primary flex items-center gap-1"
href="https://docs.fossorial.io/Newt/install"
target="_blank"
rel="noopener noreferrer"
>
<span>
{" "}
Learn how to install Newt on your system
</span>
<ArrowUpRight className="w-5 h-5" />
</Link>
</>
)}
<div className="flex items-center space-x-2">
<Checkbox
id="terms"

View File

@@ -16,6 +16,7 @@ import {
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableHeader,
TableRow,
@@ -89,7 +90,7 @@ export function SitesDataTable<TData, TValue>({
<Plus className="mr-2 h-4 w-4" /> Add Site
</Button>
</div>
<div className="border rounded-md">
<TableContainer>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
@@ -141,7 +142,7 @@ export function SitesDataTable<TData, TValue>({
)}
</TableBody>
</Table>
</div>
</TableContainer>
<div className="mt-4">
<DataTablePagination table={table} />
</div>

View File

@@ -0,0 +1,98 @@
"use client";
import React, { useState, useEffect } from "react";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { ArrowRight, DockIcon as Docker, Globe, Server, X } from "lucide-react";
import Link from "next/link";
export const SitesSplashCard = () => {
const [isDismissed, setIsDismissed] = useState(true);
const key = "sites-splash-card-dismissed";
useEffect(() => {
const dismissed = localStorage.getItem(key);
if (dismissed === "true") {
setIsDismissed(true);
} else {
setIsDismissed(false);
}
}, []);
const handleDismiss = () => {
setIsDismissed(true);
localStorage.setItem(key, "true");
};
if (isDismissed) {
return null;
}
return (
<Card className="w-full mx-auto overflow-hidden mb-8 hidden md:block relative">
<button
onClick={handleDismiss}
className="absolute top-2 right-2 p-2"
aria-label="Dismiss"
>
<X className="w-5 h-5" />
</button>
<CardContent className="grid gap-6 p-6 sm:grid-cols-2">
<div className="space-y-4">
<h3 className="text-xl font-semibold flex items-center gap-2">
<Globe className="text-blue-500" />
Newt (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.
</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
</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
</li>
</ul>
<Button className="w-full" variant="secondary">
<Link
href="https://docs.fossorial.io/Newt/install"
target="_blank"
rel="noopener noreferrer"
className="flex items-center"
>
Install Newt <ArrowRight className="ml-2 w-4 h-4" />
</Link>
</Button>
</div>
<div className="space-y-4">
<h3 className="text-xl font-semibold flex items-center gap-2">
Basic WireGuard
</h3>
<p className="text-sm">
Use any WireGuard client to connect. You will have to
address your internal resources using the peer IP.
</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
</li>
<li className="flex items-center gap-2">
<Server className="text-purple-500 w-4 h-4" />
Manual configuration required
</li>
</ul>
</div>
</CardContent>
</Card>
);
};
export default SitesSplashCard;

View File

@@ -256,7 +256,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
<Link
href={`/${siteRow.orgId}/settings/sites/${siteRow.nice}`}
>
<Button variant={"gray"} className="ml-2">
<Button variant={"outline"} className="ml-2">
Edit
<ArrowRight className="ml-2 w-4 h-4" />
</Button>

View File

@@ -10,20 +10,29 @@ import {
FormField,
FormItem,
FormLabel,
FormMessage,
FormMessage
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { useSiteContext } from "@app/hooks/useSiteContext";
import { useForm } from "react-hook-form";
import { useToast } from "@app/hooks/useToast";
import { useRouter } from "next/navigation";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { formatAxiosError } from "@app/lib/api";;
import {
SettingsContainer,
SettingsSection,
SettingsSectionHeader,
SettingsSectionTitle,
SettingsSectionDescription,
SettingsSectionBody,
SettingsSectionForm,
SettingsSectionFooter
} from "@app/components/Settings";
import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
const GeneralFormSchema = z.object({
name: z.string(),
name: z.string()
});
type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
@@ -39,15 +48,15 @@ export default function GeneralPage() {
const form = useForm<GeneralFormValues>({
resolver: zodResolver(GeneralFormSchema),
defaultValues: {
name: site?.name,
name: site?.name
},
mode: "onChange",
mode: "onChange"
});
async function onSubmit(data: GeneralFormValues) {
await api
.post(`/site/${site?.siteId}`, {
name: data.name,
name: data.name
})
.catch((e) => {
toast({
@@ -56,7 +65,7 @@ export default function GeneralPage() {
description: formatAxiosError(
e,
"An error occurred while updating the site."
),
)
});
});
@@ -66,39 +75,53 @@ export default function GeneralPage() {
}
return (
<>
<div className="space-y-4 max-w-xl">
<SettingsSectionTitle
title="General Settings"
description="Configure the general settings for this site"
size="1xl"
/>
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
General Settings
</SettingsSectionTitle>
<SettingsSectionDescription>
Configure the general settings for this site
</SettingsSectionDescription>
</SettingsSectionHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
This is the display name of the site
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">Save Changes</Button>
</form>
</Form>
</div>
</>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
id="general-settings-form"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
This is the display name of the
site
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button type="submit" form="general-settings-form">
Save Settings
</Button>
</SettingsSectionFooter>
</SettingsSection>
</SettingsContainer>
);
}

View File

@@ -4,6 +4,7 @@ import { ListSitesResponse } from "@server/routers/site";
import { AxiosResponse } from "axios";
import SitesTable, { SiteRow } from "./SitesTable";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import SitesSplashCard from "./SitesSplashCard";
type SitesPageProps = {
params: Promise<{ orgId: string }>;
@@ -47,6 +48,8 @@ export default async function SitesPage(props: SitesPageProps) {
return (
<>
<SitesSplashCard />
<SettingsSectionTitle
title="Manage Sites"
description="Allow connectivity to your network through secure tunnels"

View File

@@ -12,6 +12,7 @@ import LoginForm from "@app/components/LoginForm";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import Image from "next/image";
type DashboardLoginFormProps = {
redirect?: string;
@@ -37,10 +38,20 @@ export default function DashboardLoginForm({
return (
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>Welcome to Pangolin</CardTitle>
<CardDescription>
Enter your credentials to access your dashboard
</CardDescription>
<div className="flex flex-row items-center justify-center">
<Image
src={`/logo/pangolin_orange.svg`}
alt="Pangolin Logo"
width="100"
height="100"
/>
</div>
<div className="text-center space-y-1">
<h1 className="text-2xl font-bold mt-1">
Welcome to Pangolin
</h1>
<p className="text-sm text-muted-foreground">Log in to get started</p>
</div>
</CardHeader>
<CardContent>
<LoginForm

View File

@@ -4,6 +4,7 @@ import { redirect } from "next/navigation";
import { cache } from "react";
import DashboardLoginForm from "./DashboardLoginForm";
import { Mail } from "lucide-react";
import { pullEnv } from "@app/lib/pullEnv";
export const dynamic = "force-dynamic";
@@ -16,7 +17,9 @@ export default async function Page(props: {
const isInvite = searchParams?.redirect?.includes("/invite");
const signUpDisabled = process.env.DISABLE_SIGNUP_WITHOUT_INVITE === "true";
const env = pullEnv();
const signUpDisabled = env.flags.disableSignupWithoutInvite;
if (user) {
redirect("/");

View File

@@ -364,8 +364,6 @@ export default function ResetPasswordForm({
<InputOTPSlot
index={2}
/>
</InputOTPGroup>
<InputOTPGroup>
<InputOTPSlot
index={3}
/>

View File

@@ -38,7 +38,7 @@ export default async function Page(props: {
}
className="underline"
>
Go to login
Go back to log in
</Link>
</p>
</>

View File

@@ -16,6 +16,7 @@ import { cookies } from "next/headers";
import { CheckResourceSessionResponse } from "@server/routers/auth";
import AccessTokenInvalid from "./AccessToken";
import AccessToken from "./AccessToken";
import { pullEnv } from "@app/lib/pullEnv";
export default async function ResourceAuthPage(props: {
params: Promise<{ resourceId: number }>;
@@ -27,6 +28,8 @@ export default async function ResourceAuthPage(props: {
const params = await props.params;
const searchParams = await props.searchParams;
const env = pullEnv();
let authInfo: GetResourceAuthInfoResponse | undefined;
try {
const res = await internal.get<
@@ -42,7 +45,9 @@ export default async function ResourceAuthPage(props: {
const user = await getUser({ skipCheckVerifyEmail: true });
if (!authInfo) {
{/* @ts-ignore */} // TODO: fix this
{
/* @ts-ignore */
} // TODO: fix this
return (
<div className="w-full max-w-md">
<ResourceNotFound />
@@ -63,11 +68,7 @@ export default async function ResourceAuthPage(props: {
!authInfo.pincode &&
!authInfo.whitelist;
if (
user &&
!user.emailVerified &&
process.env.FLAGS_EMAIL_VERIFICATION_REQUIRED === "true"
) {
if (user && !user.emailVerified && env.flags.emailVerificationRequired) {
redirect(
`/auth/verify-email?redirect=/auth/resource/${authInfo.resourceId}`
);
@@ -75,7 +76,7 @@ export default async function ResourceAuthPage(props: {
const allCookies = await cookies();
const cookieName =
process.env.RESOURCE_SESSION_COOKIE_NAME + `_${params.resourceId}`;
env.server.resourceSessionCookieName + `_${params.resourceId}`;
const sessionId = allCookies.get(cookieName)?.value ?? null;
if (sessionId) {

View File

@@ -12,23 +12,24 @@ import {
FormField,
FormItem,
FormLabel,
FormMessage,
FormMessage
} from "@/components/ui/form";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
CardTitle
} from "@/components/ui/card";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { SignUpResponse } from "@server/routers/auth";
import { useRouter } from "next/navigation";
import { passwordSchema } from "@server/auth/passwordSchema";
import { AxiosResponse } from "axios";
import { formatAxiosError } from "@app/lib/api";;
import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import Image from "next/image";
type SignupFormProps = {
redirect?: string;
@@ -40,14 +41,18 @@ const formSchema = z
.object({
email: z.string().email({ message: "Invalid email address" }),
password: passwordSchema,
confirmPassword: passwordSchema,
confirmPassword: passwordSchema
})
.refine((data) => data.password === data.confirmPassword, {
path: ["confirmPassword"],
message: "Passwords do not match",
message: "Passwords do not match"
});
export default function SignupForm({ redirect, inviteId, inviteToken }: SignupFormProps) {
export default function SignupForm({
redirect,
inviteId,
inviteToken
}: SignupFormProps) {
const router = useRouter();
const api = createApiClient(useEnvContext());
@@ -60,8 +65,8 @@ export default function SignupForm({ redirect, inviteId, inviteToken }: SignupFo
defaultValues: {
email: "",
password: "",
confirmPassword: "",
},
confirmPassword: ""
}
});
async function onSubmit(values: z.infer<typeof formSchema>) {
@@ -109,10 +114,22 @@ export default function SignupForm({ redirect, inviteId, inviteToken }: SignupFo
return (
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>Create Account</CardTitle>
<CardDescription>
Enter your details to create an account
</CardDescription>
<div className="flex flex-row items-center justify-center">
<Image
src={`/logo/pangolin_orange.svg`}
alt="Pangolin Logo"
width="100"
height="100"
/>
</div>
<div className="text-center space-y-1">
<h1 className="text-2xl font-bold mt-1">
Welcome to Pangolin
</h1>
<p className="text-sm text-muted-foreground">
Create an account to get started
</p>
</div>
</CardHeader>
<CardContent>
<Form {...form}>

View File

@@ -1,5 +1,6 @@
import VerifyEmailForm from "@app/app/auth/verify-email/VerifyEmailForm";
import { verifySession } from "@app/lib/auth/verifySession";
import { pullEnv } from "@app/lib/pullEnv";
import { redirect } from "next/navigation";
import { cache } from "react";
@@ -8,7 +9,9 @@ export const dynamic = "force-dynamic";
export default async function Page(props: {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}) {
if (process.env.FLAGS_EMAIL_VERIFICATION_REQUIRED !== "true") {
const env = pullEnv();
if (!env.flags.emailVerificationRequired) {
redirect("/");
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -15,14 +15,14 @@
--primary-foreground: 60 9.1% 97.8%;
--secondary: 60 4.8% 95.9%;
--secondary-foreground: 24 9.8% 10%;
--muted: 60 4.8% 95.9%;
--muted: 60 4.8% 85.0%;
--muted-foreground: 25 5.3% 44.7%;
--accent: 60 4.8% 95.9%;
--accent: 60 4.8% 90%;
--accent-foreground: 24 9.8% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 60 9.1% 97.8%;
--border: 20 5.9% 90%;
--input: 20 5.9% 90%;
--border: 20 5.9% 85%;
--input: 20 5.9% 85%;
--ring: 24.6 95% 53.1%;
--radius: 0.75rem;
--chart-1: 12 76% 61%;
@@ -41,11 +41,11 @@
--popover-foreground: 60 9.1% 97.8%;
--primary: 20.5 90.2% 48.2%;
--primary-foreground: 60 9.1% 97.8%;
--secondary: 12 6.5% 25.0%;
--secondary: 12 6.5% 15.0%;
--secondary-foreground: 60 9.1% 97.8%;
--muted: 12 6.5% 25.0%;
--muted-foreground: 24 5.4% 63.9%;
--accent: 12 6.5% 25.0%;
--accent: 12 2.5% 15.0%;
--accent-foreground: 60 9.1% 97.8%;
--destructive: 0 72.2% 50.6%;
--destructive-foreground: 60 9.1% 97.8%;

View File

@@ -1,24 +1,28 @@
import type { Metadata } from "next";
import "./globals.css";
import { Figtree } from "next/font/google";
import { Figtree, Inter } from "next/font/google";
import { Toaster } from "@/components/ui/toaster";
import { ThemeProvider } from "@app/providers/ThemeProvider";
import EnvProvider from "@app/providers/EnvProvider";
import { Separator } from "@app/components/ui/separator";
import { pullEnv } from "@app/lib/pullEnv";
export const metadata: Metadata = {
title: `Dashboard - Pangolin`,
description: ""
};
const font = Figtree({ subsets: ["latin"] });
// const font = Figtree({ subsets: ["latin"] });
const font = Inter({ subsets: ["latin"] });
export default async function RootLayout({
children
}: Readonly<{
children: React.ReactNode;
}>) {
const version = process.env.APP_VERSION;
const env = pullEnv();
const version = env.app.version;
return (
<html suppressHydrationWarning>
@@ -29,24 +33,12 @@ export default async function RootLayout({
enableSystem
disableTransitionOnChange
>
<EnvProvider
env={{
NEXT_PORT: process.env.NEXT_PORT as string,
SERVER_EXTERNAL_PORT: process.env
.SERVER_EXTERNAL_PORT as string,
ENVIRONMENT: process.env.ENVIRONMENT as string,
EMAIL_ENABLED: process.env.EMAIL_ENABLED as string,
DISABLE_USER_CREATE_ORG:
process.env.DISABLE_USER_CREATE_ORG,
DISABLE_SIGNUP_WITHOUT_INVITE:
process.env.DISABLE_SIGNUP_WITHOUT_INVITE
}}
>
<EnvProvider env={pullEnv()}>
{/* Main content */}
<div className="flex-grow">{children}</div>
{/* Footer */}
<footer className="w-full mt-12 py-3 mb-4">
<footer className="w-full mt-12 py-3 mb-6">
<div className="container mx-auto flex flex-wrap justify-center items-center h-4 space-x-4 text-sm text-neutral-400 select-none">
<div className="whitespace-nowrap">
Pangolin
@@ -73,6 +65,16 @@ export default async function RootLayout({
<path d="M12 0C5.37 0 0 5.373 0 12c0 5.303 3.438 9.8 8.207 11.385.6.11.82-.26.82-.577v-2.17c-3.338.726-4.042-1.61-4.042-1.61-.546-1.385-1.333-1.755-1.333-1.755-1.09-.744.082-.73.082-.73 1.205.085 1.84 1.24 1.84 1.24 1.07 1.835 2.807 1.305 3.492.997.107-.775.42-1.305.763-1.605-2.665-.305-5.467-1.335-5.467-5.93 0-1.31.468-2.382 1.236-3.22-.123-.303-.535-1.523.117-3.176 0 0 1.008-.322 3.3 1.23a11.52 11.52 0 013.006-.403c1.02.005 2.045.137 3.006.403 2.29-1.552 3.295-1.23 3.295-1.23.654 1.653.242 2.873.12 3.176.77.838 1.235 1.91 1.235 3.22 0 4.605-2.805 5.623-5.475 5.92.43.37.814 1.1.814 2.22v3.293c0 .32.217.693.825.576C20.565 21.795 24 17.298 24 12 24 5.373 18.627 0 12 0z" />
</svg>
</a>
<Separator orientation="vertical" />
<a
href="https://docs.fossorial.io/Pangolin/overview"
target="_blank"
rel="noopener noreferrer"
aria-label="GitHub"
className="flex items-center space-x-3 whitespace-nowrap"
>
<span>Docs</span>
</a>
{version && (
<>
<Separator orientation="vertical" />

View File

@@ -10,6 +10,7 @@ import Link from "next/link";
import { redirect } from "next/navigation";
import { cache } from "react";
import OrganizationLanding from "./components/OrganizationLanding";
import { pullEnv } from "@app/lib/pullEnv";
export const dynamic = "force-dynamic";
@@ -21,6 +22,8 @@ export default async function Page(props: {
}) {
const params = await props.searchParams; // this is needed to prevent static optimization
const env = pullEnv();
const getUser = cache(verifySession);
const user = await getUser({ skipCheckVerifyEmail: true });
@@ -34,7 +37,7 @@ export default async function Page(props: {
if (
!user.emailVerified &&
process.env.FLAGS_EMAIL_VERIFICATION_REQUIRED === "true"
env.flags.emailVerificationRequired
) {
if (params.redirect) {
redirect(`/auth/verify-email?redirect=${params.redirect}`);
@@ -57,7 +60,7 @@ export default async function Page(props: {
if (!orgs.length) {
if (
process.env.DISABLE_USER_CREATE_ORG === "false" ||
!env.flags.disableUserCreateOrg ||
user.serverAdmin
) {
redirect("/setup");

View File

@@ -1,5 +1,6 @@
import ProfileIcon from "@app/components/ProfileIcon";
import { verifySession } from "@app/lib/auth/verifySession";
import { pullEnv } from "@app/lib/pullEnv";
import UserProvider from "@app/providers/UserProvider";
import { Metadata } from "next";
import { redirect } from "next/navigation";
@@ -20,12 +21,14 @@ export default async function SetupLayout({
const getUser = cache(verifySession);
const user = await getUser();
const env = pullEnv();
if (!user) {
redirect("/?redirect=/setup");
}
if (
!(process.env.DISABLE_USER_CREATE_ORG === "false" || user.serverAdmin)
!(!env.flags.disableUserCreateOrg || user.serverAdmin)
) {
redirect("/");
}