Compare commits
8 commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
aabdcea3c0 | ||
![]() |
a178faa377 | ||
![]() |
edf0ce226f | ||
![]() |
7118ae374d | ||
![]() |
f2a14e6a36 | ||
![]() |
f37be774a6 | ||
![]() |
0dcfeb3587 | ||
![]() |
33e8ed4c93 |
17 changed files with 571 additions and 83 deletions
10
README.md
10
README.md
|
@ -18,12 +18,12 @@ _Your own self-hosted zero trust tunnel._
|
|||
|
||||
<div align="center">
|
||||
<h5>
|
||||
<a href="https://docs.fossorial.io/Getting%20Started/quick-install">
|
||||
Install Guide
|
||||
<a href="https://fossorial.io">
|
||||
Website
|
||||
</a>
|
||||
<span> | </span>
|
||||
<a href="https://docs.fossorial.io">
|
||||
Full Documentation
|
||||
<a href="https://docs.fossorial.io/Getting%20Started/quick-install">
|
||||
Install Guide
|
||||
</a>
|
||||
<span> | </span>
|
||||
<a href="mailto:numbat@fossorial.io">
|
||||
|
@ -136,7 +136,7 @@ View the [project board](https://github.com/orgs/fosrl/projects/1) for more deta
|
|||
|
||||
## Licensing
|
||||
|
||||
Pangolin is dual licensed under the AGPLv3 and the Fossorial Commercial license. For inquiries about commercial licensing, please contact us at [numbat@fossorial.io](mailto:numbat@fossorial.io).
|
||||
Pangolin is dual licensed under the AGPL-3 and the Fossorial Commercial license. To see our commercial offerings, please see our [website](https://fossorial.io) for details. For inquiries about commercial licensing, please contact us at [numbat@fossorial.io](mailto:numbat@fossorial.io).
|
||||
|
||||
## Contributions
|
||||
|
||||
|
|
|
@ -170,16 +170,17 @@ export function serializeResourceSessionCookie(
|
|||
isHttp: boolean = false,
|
||||
expiresAt?: Date
|
||||
): string {
|
||||
const now = new Date().getTime();
|
||||
if (!isHttp) {
|
||||
if (expiresAt === undefined) {
|
||||
return `${cookieName}_s=${token}; HttpOnly; SameSite=Lax; Path=/; Secure; Domain=${"." + domain}`;
|
||||
return `${cookieName}_s.${now}=${token}; HttpOnly; SameSite=Lax; Path=/; Secure; Domain=${"." + domain}`;
|
||||
}
|
||||
return `${cookieName}_s=${token}; HttpOnly; SameSite=Lax; Expires=${expiresAt.toUTCString()}; Path=/; Secure; Domain=${"." + domain}`;
|
||||
return `${cookieName}_s.${now}=${token}; HttpOnly; SameSite=Lax; Expires=${expiresAt.toUTCString()}; Path=/; Secure; Domain=${"." + domain}`;
|
||||
} else {
|
||||
if (expiresAt === undefined) {
|
||||
return `${cookieName}=${token}; HttpOnly; SameSite=Lax; Path=/; Domain=${"." + domain}`;
|
||||
return `${cookieName}.${now}=${token}; HttpOnly; SameSite=Lax; Path=/; Domain=${"." + domain}`;
|
||||
}
|
||||
return `${cookieName}=${token}; HttpOnly; SameSite=Lax; Expires=${expiresAt.toUTCString()}; Path=/; Domain=${"." + domain}`;
|
||||
return `${cookieName}.${now}=${token}; HttpOnly; SameSite=Lax; Expires=${expiresAt.toUTCString()}; Path=/; Domain=${"." + domain}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -245,13 +245,7 @@ export class Config {
|
|||
: "false";
|
||||
process.env.DASHBOARD_URL = parsedConfig.data.app.dashboard_url;
|
||||
|
||||
this.checkSupporterKey()
|
||||
.then(() => {
|
||||
console.log("Supporter key checked");
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error checking supporter key:", error);
|
||||
});
|
||||
this.checkSupporterKey();
|
||||
|
||||
this.rawConfig = parsedConfig.data;
|
||||
}
|
||||
|
@ -299,43 +293,44 @@ export class Config {
|
|||
|
||||
const { key: licenseKey, githubUsername } = key;
|
||||
|
||||
const response = await fetch(
|
||||
"https://api.dev.fossorial.io/api/v1/license/validate",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
licenseKey,
|
||||
githubUsername
|
||||
})
|
||||
try {
|
||||
const response = await fetch(
|
||||
"https://api.dev.fossorial.io/api/v1/license/validate",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
licenseKey,
|
||||
githubUsername
|
||||
})
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
this.supporterData = key;
|
||||
return;
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
this.supporterData = key;
|
||||
return;
|
||||
}
|
||||
const data = await response.json();
|
||||
|
||||
const data = await response.json();
|
||||
if (!data.data.valid) {
|
||||
this.supporterData = {
|
||||
...key,
|
||||
valid: false
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
if (!data.data.valid) {
|
||||
this.supporterData = {
|
||||
...key,
|
||||
valid: false
|
||||
tier: data.data.tier,
|
||||
valid: true
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
this.supporterData = {
|
||||
...key,
|
||||
tier: data.data.tier,
|
||||
valid: true
|
||||
};
|
||||
|
||||
// update the supporter key in the database
|
||||
await db
|
||||
// update the supporter key in the database
|
||||
await db
|
||||
.update(supporterKey)
|
||||
.set({
|
||||
tier: data.data.tier || null,
|
||||
|
@ -343,6 +338,10 @@ export class Config {
|
|||
valid: true
|
||||
})
|
||||
.where(eq(supporterKey.keyId, key.keyId));
|
||||
} catch (e) {
|
||||
this.supporterData = key;
|
||||
console.error("Failed to validate supporter key", e);
|
||||
}
|
||||
}
|
||||
|
||||
public getSupporterData() {
|
||||
|
|
|
@ -14,7 +14,7 @@ export async function verifyUserIsServerAdmin(
|
|||
createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated")
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
if (!req.user?.serverAdmin) {
|
||||
return next(
|
||||
|
@ -24,7 +24,7 @@ export async function verifyUserIsServerAdmin(
|
|||
)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
return next();
|
||||
} catch (e) {
|
||||
return next(
|
||||
|
|
|
@ -229,12 +229,10 @@ export async function verifyResourceSession(
|
|||
return notAllowed(res);
|
||||
}
|
||||
|
||||
const resourceSessionToken =
|
||||
sessions[
|
||||
`${config.getRawConfig().server.session_cookie_name}${
|
||||
resource.ssl ? "_s" : ""
|
||||
}`
|
||||
];
|
||||
const resourceSessionToken = extractResourceSessionToken(
|
||||
sessions,
|
||||
resource.ssl
|
||||
);
|
||||
|
||||
if (resourceSessionToken) {
|
||||
const sessionCacheKey = `session:${resourceSessionToken}`;
|
||||
|
@ -354,6 +352,50 @@ export async function verifyResourceSession(
|
|||
}
|
||||
}
|
||||
|
||||
function extractResourceSessionToken(
|
||||
sessions: Record<string, string>,
|
||||
ssl: boolean
|
||||
) {
|
||||
const prefix = `${config.getRawConfig().server.session_cookie_name}${
|
||||
ssl ? "_s" : ""
|
||||
}`;
|
||||
|
||||
const all: { cookieName: string; token: string; priority: number }[] =
|
||||
[];
|
||||
|
||||
for (const [key, value] of Object.entries(sessions)) {
|
||||
const parts = key.split(".");
|
||||
const timestamp = parts[parts.length - 1];
|
||||
|
||||
// check if string is only numbers
|
||||
if (!/^\d+$/.test(timestamp)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// cookie name is the key without the timestamp
|
||||
const cookieName = key.slice(0, -timestamp.length - 1);
|
||||
|
||||
if (cookieName === prefix) {
|
||||
all.push({
|
||||
cookieName,
|
||||
token: value,
|
||||
priority: parseInt(timestamp)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// sort by priority in desc order
|
||||
all.sort((a, b) => b.priority - a.priority);
|
||||
|
||||
const latest = all[0];
|
||||
|
||||
if (!latest) {
|
||||
return;
|
||||
}
|
||||
|
||||
return latest.token;
|
||||
}
|
||||
|
||||
function notAllowed(res: Response, redirectUrl?: string) {
|
||||
const data = {
|
||||
data: { valid: false, redirectUrl },
|
||||
|
@ -612,21 +654,21 @@ export function isPathAllowed(pattern: string, path: string): boolean {
|
|||
logger.debug(
|
||||
`${indent}Found in-segment wildcard in "${currentPatternPart}"`
|
||||
);
|
||||
|
||||
|
||||
// Convert the pattern segment to a regex pattern
|
||||
const regexPattern = currentPatternPart
|
||||
.replace(/\*/g, ".*") // Replace * with .* for regex wildcard
|
||||
.replace(/\?/g, "."); // Replace ? with . for single character wildcard if needed
|
||||
|
||||
|
||||
const regex = new RegExp(`^${regexPattern}$`);
|
||||
|
||||
|
||||
if (regex.test(currentPathPart)) {
|
||||
logger.debug(
|
||||
`${indent}Segment with wildcard matches: "${currentPatternPart}" matches "${currentPathPart}"`
|
||||
);
|
||||
return matchSegments(patternIndex + 1, pathIndex + 1);
|
||||
}
|
||||
|
||||
|
||||
logger.debug(
|
||||
`${indent}Segment with wildcard mismatch: "${currentPatternPart}" doesn't match "${currentPathPart}"`
|
||||
);
|
||||
|
@ -651,4 +693,4 @@ export function isPathAllowed(pattern: string, path: string): boolean {
|
|||
const result = matchSegments(0, 0);
|
||||
logger.debug(`Final result: ${result}`);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,6 +10,7 @@ import { users } from "@server/db/schema";
|
|||
|
||||
export type IsSupporterKeyVisibleResponse = {
|
||||
visible: boolean;
|
||||
tier?: string;
|
||||
};
|
||||
|
||||
const USER_LIMIT = 5;
|
||||
|
@ -29,16 +30,17 @@ export async function isSupporterKeyVisible(
|
|||
const [numUsers] = await db.select({ count: count() }).from(users);
|
||||
|
||||
if (numUsers.count > USER_LIMIT) {
|
||||
logger.debug(
|
||||
`User count ${numUsers.count} exceeds limit ${USER_LIMIT}`
|
||||
);
|
||||
visible = true;
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug(`Supporter key visible: ${visible}`);
|
||||
logger.debug(JSON.stringify(key));
|
||||
|
||||
return sendResponse<IsSupporterKeyVisibleResponse>(res, {
|
||||
data: {
|
||||
visible
|
||||
visible,
|
||||
tier: key?.tier || undefined
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
|
|
|
@ -31,6 +31,7 @@ async function queryUsers(limit: number, offset: number) {
|
|||
id: users.userId,
|
||||
email: users.email,
|
||||
dateCreated: users.dateCreated,
|
||||
serverAdmin: users.serverAdmin
|
||||
})
|
||||
.from(users)
|
||||
.where(eq(users.serverAdmin, false))
|
||||
|
@ -60,10 +61,7 @@ export async function adminListUsers(
|
|||
}
|
||||
const { limit, offset } = parsedQuery.data;
|
||||
|
||||
const allUsers = await queryUsers(
|
||||
limit,
|
||||
offset
|
||||
);
|
||||
const allUsers = await queryUsers(limit, offset);
|
||||
|
||||
const [{ count }] = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db } from "@server/db";
|
||||
import { userOrgs, users } from "@server/db/schema";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { users } from "@server/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
|
@ -36,13 +36,22 @@ export async function adminRemoveUser(
|
|||
// get the user first
|
||||
const user = await db
|
||||
.select()
|
||||
.from(userOrgs)
|
||||
.where(eq(userOrgs.userId, userId));
|
||||
.from(users)
|
||||
.where(eq(users.userId, userId));
|
||||
|
||||
if (!user || user.length === 0) {
|
||||
return next(createHttpError(HttpCode.NOT_FOUND, "User not found"));
|
||||
}
|
||||
|
||||
if (user[0].serverAdmin) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Cannot remove server admin"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
await db.delete(users).where(eq(users.userId, userId));
|
||||
|
||||
return response(res, {
|
||||
|
|
|
@ -6,4 +6,4 @@ export * from "./inviteUser";
|
|||
export * from "./acceptInvite";
|
||||
export * from "./getOrgUser";
|
||||
export * from "./adminListUsers";
|
||||
export * from "./adminRemoveUser";
|
||||
export * from "./adminRemoveUser";
|
||||
|
|
71
src/app/admin/layout.tsx
Normal file
71
src/app/admin/layout.tsx
Normal file
|
@ -0,0 +1,71 @@
|
|||
import { Metadata } from "next";
|
||||
import { TopbarNav } from "@app/components/TopbarNav";
|
||||
import { Users } from "lucide-react";
|
||||
import { Header } from "@app/components/Header";
|
||||
import { verifySession } from "@app/lib/auth/verifySession";
|
||||
import { redirect } from "next/navigation";
|
||||
import { cache } from "react";
|
||||
import UserProvider from "@app/providers/UserProvider";
|
||||
import { ListOrgsResponse } from "@server/routers/org";
|
||||
import { internal } from "@app/lib/api";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: `Server Admin - Pangolin`,
|
||||
description: ""
|
||||
};
|
||||
|
||||
const topNavItems = [
|
||||
{
|
||||
title: "All Users",
|
||||
href: "/admin/users",
|
||||
icon: <Users className="h-4 w-4" />
|
||||
}
|
||||
];
|
||||
|
||||
interface LayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default async function SettingsLayout(props: LayoutProps) {
|
||||
const getUser = cache(verifySession);
|
||||
const user = await getUser();
|
||||
|
||||
if (!user || !user.serverAdmin) {
|
||||
redirect(`/`);
|
||||
}
|
||||
|
||||
const cookie = await authCookieHeader();
|
||||
let orgs: ListOrgsResponse["orgs"] = [];
|
||||
try {
|
||||
const getOrgs = cache(() =>
|
||||
internal.get<AxiosResponse<ListOrgsResponse>>(`/orgs`, cookie)
|
||||
);
|
||||
const res = await getOrgs();
|
||||
if (res && res.data.data.orgs) {
|
||||
orgs = res.data.data.orgs;
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-full bg-card sm:px-0 fixed top-0 z-10 border-b">
|
||||
<div className="container mx-auto flex flex-col content-between">
|
||||
<div className="my-4 px-3 md:px-0">
|
||||
<UserProvider user={user}>
|
||||
<Header orgId={""} orgs={orgs} />
|
||||
</UserProvider>
|
||||
</div>
|
||||
<TopbarNav items={topNavItems} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="container mx-auto sm:px-0 px-3 pt-[155px]">
|
||||
{props.children}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
11
src/app/admin/page.tsx
Normal file
11
src/app/admin/page.tsx
Normal file
|
@ -0,0 +1,11 @@
|
|||
import { verifySession } from "@app/lib/auth/verifySession";
|
||||
import { cache } from "react";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
type AdminPageProps = {};
|
||||
|
||||
export default async function OrgPage(props: AdminPageProps) {
|
||||
redirect(`/admin/users`);
|
||||
|
||||
return <></>;
|
||||
}
|
141
src/app/admin/users/AdminUsersDataTable.tsx
Normal file
141
src/app/admin/users/AdminUsersDataTable.tsx
Normal file
|
@ -0,0 +1,141 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
ColumnDef,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
getPaginationRowModel,
|
||||
SortingState,
|
||||
getSortedRowModel,
|
||||
ColumnFiltersState,
|
||||
getFilteredRowModel
|
||||
} from "@tanstack/react-table";
|
||||
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow
|
||||
} from "@/components/ui/table";
|
||||
import { useState } from "react";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { DataTablePagination } from "@app/components/DataTablePagination";
|
||||
import { Search } from "lucide-react";
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
}
|
||||
|
||||
export function UsersDataTable<TData, TValue>({
|
||||
columns,
|
||||
data
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
onSortingChange: setSorting,
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
initialState: {
|
||||
pagination: {
|
||||
pageSize: 20,
|
||||
pageIndex: 0
|
||||
}
|
||||
},
|
||||
state: {
|
||||
sorting,
|
||||
columnFilters
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between pb-4">
|
||||
<div className="flex items-center max-w-sm mr-2 w-full relative">
|
||||
<Input
|
||||
placeholder="Search server users"
|
||||
value={
|
||||
(table
|
||||
.getColumn("email")
|
||||
?.getFilterValue() as string) ?? ""
|
||||
}
|
||||
onChange={(event) =>
|
||||
table
|
||||
.getColumn("name")
|
||||
?.setFilterValue(event.target.value)
|
||||
}
|
||||
className="w-full pl-8"
|
||||
/>
|
||||
<Search className="h-4 w-4 absolute left-2 top-1/2 transform -translate-y-1/2" />
|
||||
</div>
|
||||
</div>
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<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}
|
||||
data-state={
|
||||
row.getIsSelected() && "selected"
|
||||
}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
This server has no users.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<div className="mt-4">
|
||||
<DataTablePagination table={table} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
151
src/app/admin/users/AdminUsersTable.tsx
Normal file
151
src/app/admin/users/AdminUsersTable.tsx
Normal file
|
@ -0,0 +1,151 @@
|
|||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { UsersDataTable } from "./AdminUsersDataTable";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { ArrowRight, ArrowUpDown } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { formatAxiosError } from "@app/lib/api";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
|
||||
export type GlobalUserRow = {
|
||||
id: string;
|
||||
email: string;
|
||||
dateCreated: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
users: GlobalUserRow[];
|
||||
};
|
||||
|
||||
export default function UsersTable({ users }: Props) {
|
||||
const router = useRouter();
|
||||
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [selected, setSelected] = useState<GlobalUserRow | null>(null);
|
||||
const [rows, setRows] = useState<GlobalUserRow[]>(users);
|
||||
|
||||
const api = createApiClient(useEnvContext());
|
||||
|
||||
const deleteUser = (id: string) => {
|
||||
api.delete(`/user/${id}`)
|
||||
.catch((e) => {
|
||||
console.error("Error deleting user", e);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error deleting user",
|
||||
description: formatAxiosError(e, "Error deleting user")
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
router.refresh();
|
||||
setIsDeleteModalOpen(false);
|
||||
|
||||
const newRows = rows.filter((row) => row.id !== id);
|
||||
|
||||
setRows(newRows);
|
||||
});
|
||||
};
|
||||
|
||||
const columns: ColumnDef<GlobalUserRow>[] = [
|
||||
{
|
||||
accessorKey: "id",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
ID
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "email",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Email
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-end">
|
||||
<Button
|
||||
variant={"outlinePrimary"}
|
||||
className="ml-2"
|
||||
onClick={() => {
|
||||
setSelected(r);
|
||||
setIsDeleteModalOpen(true);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{selected && (
|
||||
<ConfirmDeleteDialog
|
||||
open={isDeleteModalOpen}
|
||||
setOpen={(val) => {
|
||||
setIsDeleteModalOpen(val);
|
||||
setSelected(null);
|
||||
}}
|
||||
dialog={
|
||||
<div className="space-y-4">
|
||||
<p>
|
||||
Are you sure you want to permanently delete{" "}
|
||||
<b>{selected?.email || selected?.id}</b> from
|
||||
the server?
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<b>
|
||||
The user will be removed from all
|
||||
organizations and be completely removed from
|
||||
the server.
|
||||
</b>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
To confirm, please type the email of the user
|
||||
below.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
buttonText="Confirm Delete User"
|
||||
onConfirm={async () => deleteUser(selected!.id)}
|
||||
string={selected.email}
|
||||
title="Delete User from Server"
|
||||
/>
|
||||
)}
|
||||
|
||||
<UsersDataTable columns={columns} data={rows} />
|
||||
</>
|
||||
);
|
||||
}
|
44
src/app/admin/users/page.tsx
Normal file
44
src/app/admin/users/page.tsx
Normal file
|
@ -0,0 +1,44 @@
|
|||
import { internal } from "@app/lib/api";
|
||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
import { AxiosResponse } from "axios";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import { AdminListUsersResponse } from "@server/routers/user/adminListUsers";
|
||||
import UsersTable, { GlobalUserRow } from "./AdminUsersTable";
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<{ orgId: string }>;
|
||||
};
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function UsersPage(props: PageProps) {
|
||||
let rows: AdminListUsersResponse["users"] = [];
|
||||
try {
|
||||
const res = await internal.get<AxiosResponse<AdminListUsersResponse>>(
|
||||
`/users`,
|
||||
await authCookieHeader()
|
||||
);
|
||||
rows = res.data.data.users;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
const userRows: GlobalUserRow[] = rows.map((row) => {
|
||||
return {
|
||||
id: row.id,
|
||||
email: row.email,
|
||||
dateCreated: row.dateCreated,
|
||||
serverAdmin: row.serverAdmin
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSectionTitle
|
||||
title="Manage All Users"
|
||||
description="View and manage all users in the system"
|
||||
/>
|
||||
<UsersTable users={userRows} />
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -32,12 +32,13 @@ export default async function RootLayout({
|
|||
|
||||
let supporterData = {
|
||||
visible: true
|
||||
};
|
||||
} as any;
|
||||
|
||||
const res = await priv.get<
|
||||
AxiosResponse<IsSupporterKeyVisibleResponse>
|
||||
>("supporter-key/visible");
|
||||
supporterData.visible = res.data.data.visible;
|
||||
supporterData.tier = res.data.data.tier;
|
||||
|
||||
const version = env.app.version;
|
||||
|
||||
|
|
|
@ -167,7 +167,7 @@ export default function SupporterStatus() {
|
|||
</Link>{" "}
|
||||
and redeem it here.{" "}
|
||||
<Link
|
||||
href="https://supporters.dev.fossorial.io/"
|
||||
href="https://docs.fossorial.io/supporter-program"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
|
@ -208,7 +208,7 @@ export default function SupporterStatus() {
|
|||
</CardContent>
|
||||
<CardFooter>
|
||||
<Link
|
||||
href="https://www.google.com"
|
||||
href="https://github.com/sponsors/fosrl/sponsorships?tier_id=474929"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="w-full"
|
||||
|
@ -218,7 +218,9 @@ export default function SupporterStatus() {
|
|||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Card
|
||||
className={`${supporterStatus?.tier === "Limited Supporter" ? "opacity-50" : ""}`}
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle>Limited Supporter</CardTitle>
|
||||
</CardHeader>
|
||||
|
@ -246,14 +248,29 @@ export default function SupporterStatus() {
|
|||
</ul>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Link
|
||||
href="https://www.google.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="w-full"
|
||||
>
|
||||
<Button className="w-full">Buy</Button>
|
||||
</Link>
|
||||
{supporterStatus?.tier !==
|
||||
"Limited Supporter" ? (
|
||||
<Link
|
||||
href="https://github.com/sponsors/fosrl/sponsorships?tier_id=463100"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="w-full"
|
||||
>
|
||||
<Button className="w-full">
|
||||
Buy
|
||||
</Button>
|
||||
</Link>
|
||||
) : (
|
||||
<Button
|
||||
className="w-full"
|
||||
disabled={
|
||||
supporterStatus?.tier ===
|
||||
"Limited Supporter"
|
||||
}
|
||||
>
|
||||
Buy
|
||||
</Button>
|
||||
)}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
|
|
|
@ -2,6 +2,7 @@ import { createContext } from "react";
|
|||
|
||||
export type SupporterStatus = {
|
||||
visible: boolean;
|
||||
tier?: string;
|
||||
};
|
||||
|
||||
type SupporterStatusContextType = {
|
||||
|
|
Loading…
Add table
Reference in a new issue