mirror of
https://github.com/fosrl/pangolin.git
synced 2026-02-09 22:46:37 +00:00
Merge branch 'main' of https://github.com/fosrl/pangolin
This commit is contained in:
@@ -1,27 +1,25 @@
|
||||
app:
|
||||
base_url: http://localhost:3000
|
||||
base_url: https://fossorial.io
|
||||
log_level: debug
|
||||
save_logs: false
|
||||
|
||||
server:
|
||||
external_port: 3000
|
||||
internal_port: 3001
|
||||
internal_hostname: localhost
|
||||
internal_hostname: pangolin
|
||||
secure_cookies: false
|
||||
session_cookie_name: session
|
||||
resource_session_cookie_name: resource_session
|
||||
|
||||
traefik:
|
||||
cert_resolver: letsencrypt
|
||||
http_entrypoint: web
|
||||
https_entrypoint: websecure
|
||||
|
||||
badger:
|
||||
session_query_parameter: __pang_sess
|
||||
resource_session_cookie_name: resource_session
|
||||
prefer_wildcard_cert: true
|
||||
|
||||
gerbil:
|
||||
start_port: 51820
|
||||
base_endpoint: 127.0.0.1
|
||||
base_endpoint: fossorial.io
|
||||
use_subdomain: false
|
||||
block_size: 16
|
||||
subnet_group: 10.0.0.0/8
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "ENVIRONMENT=dev tsx watch server/index.ts",
|
||||
"dev": "NODE_ENV=development ENVIRONMENT=dev tsx watch server/index.ts",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:push": "npx tsx server/db/migrate.ts",
|
||||
"db:hydrate": "npx tsx scripts/hydrate.ts",
|
||||
"db:studio": "drizzle-kit studio",
|
||||
"build": "mkdir -p dist && next build && node scripts/esbuild.mjs -e server/index.ts -o dist/server.mjs",
|
||||
"start": "ENVIRONMENT=prod node dist/server.mjs",
|
||||
"start": "NODE_ENV=development ENVIRONMENT=prod node dist/server.mjs",
|
||||
"email": "email dev --dir server/emails/templates --port 3002"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -3,9 +3,13 @@ import { sha256 } from "@oslojs/crypto/sha2";
|
||||
import { resourceSessions, ResourceSession } from "@server/db/schema";
|
||||
import db from "@server/db";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import config from "@server/config";
|
||||
|
||||
export const SESSION_COOKIE_NAME = "resource_session";
|
||||
export const SESSION_COOKIE_EXPIRES = 1000 * 60 * 60 * 24 * 30;
|
||||
export const SECURE_COOKIES = config.server.secure_cookies;
|
||||
export const COOKIE_DOMAIN =
|
||||
"." + new URL(config.app.base_url).hostname.split(".").slice(-2).join(".");
|
||||
|
||||
export async function createResourceSession(opts: {
|
||||
token: string;
|
||||
@@ -115,25 +119,25 @@ export async function invalidateAllSessions(
|
||||
}
|
||||
|
||||
export function serializeResourceSessionCookie(
|
||||
cookieName: string,
|
||||
token: string,
|
||||
fqdn: string,
|
||||
secure: boolean,
|
||||
): string {
|
||||
if (secure) {
|
||||
return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Lax; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/; Secure; Domain=.localhost`;
|
||||
if (SECURE_COOKIES) {
|
||||
return `${cookieName}=${token}; HttpOnly; SameSite=Lax; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/; Secure; Domain=${COOKIE_DOMAIN}`;
|
||||
} else {
|
||||
return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Lax; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/; Domain=.localhost`;
|
||||
return `${cookieName}=${token}; HttpOnly; SameSite=Lax; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/; Domain=${COOKIE_DOMAIN}`;
|
||||
}
|
||||
}
|
||||
|
||||
export function createBlankResourceSessionTokenCookie(
|
||||
cookieName: string,
|
||||
fqdn: string,
|
||||
secure: boolean,
|
||||
): string {
|
||||
if (secure) {
|
||||
return `${SESSION_COOKIE_NAME}=; HttpOnly; SameSite=Lax; Max-Age=0; Path=/; Secure; Domain=${fqdn}`;
|
||||
if (SECURE_COOKIES) {
|
||||
return `${cookieName}=; HttpOnly; SameSite=Lax; Max-Age=0; Path=/; Secure; Domain=${COOKIE_DOMAIN}`;
|
||||
} else {
|
||||
return `${SESSION_COOKIE_NAME}=; HttpOnly; SameSite=Lax; Max-Age=0; Path=/; Domain=${fqdn}`;
|
||||
return `${cookieName}=; HttpOnly; SameSite=Lax; Max-Age=0; Path=/; Domain=${COOKIE_DOMAIN}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,9 +25,6 @@ const environmentSchema = z.object({
|
||||
secure_cookies: z.boolean(),
|
||||
signup_secret: z.string().optional(),
|
||||
session_cookie_name: z.string(),
|
||||
}),
|
||||
badger: z.object({
|
||||
session_query_parameter: z.string(),
|
||||
resource_session_cookie_name: z.string(),
|
||||
}),
|
||||
traefik: z.object({
|
||||
@@ -128,12 +125,14 @@ if (!parsedConfig.success) {
|
||||
|
||||
process.env.SERVER_EXTERNAL_PORT =
|
||||
parsedConfig.data.server.external_port.toString();
|
||||
process.env.SERVER_INTERNAL_PORT =
|
||||
parsedConfig.data.server.internal_port.toString();
|
||||
process.env.FLAGS_EMAIL_VERIFICATION_REQUIRED = parsedConfig.data.flags
|
||||
?.require_email_verification
|
||||
? "true"
|
||||
: "false";
|
||||
process.env.SESSION_COOKIE_NAME = parsedConfig.data.server.session_cookie_name;
|
||||
process.env.RESOURCE_SESSION_QUERY_PARAM_NAME =
|
||||
parsedConfig.data.badger.session_query_parameter;
|
||||
process.env.RESOURCE_SESSION_COOKIE_NAME =
|
||||
parsedConfig.data.server.resource_session_cookie_name;
|
||||
|
||||
export default parsedConfig.data;
|
||||
|
||||
@@ -287,16 +287,28 @@ export const resourceSessions = sqliteTable("resourceSessions", {
|
||||
() => resourcePassword.passwordId,
|
||||
{
|
||||
onDelete: "cascade",
|
||||
}
|
||||
},
|
||||
),
|
||||
pincodeId: integer("pincodeId").references(
|
||||
() => resourcePincode.pincodeId,
|
||||
{
|
||||
onDelete: "cascade",
|
||||
}
|
||||
},
|
||||
),
|
||||
});
|
||||
|
||||
export const resourceOtp = sqliteTable("resourceOtp", {
|
||||
otpId: integer("otpId").primaryKey({
|
||||
autoIncrement: true,
|
||||
}),
|
||||
resourceId: integer("resourceId")
|
||||
.notNull()
|
||||
.references(() => resources.resourceId, { onDelete: "cascade" }),
|
||||
email: text("email").notNull(),
|
||||
otpHash: text("otpHash").notNull(),
|
||||
expiresAt: integer("expiresAt").notNull(),
|
||||
});
|
||||
|
||||
export type Org = InferSelectModel<typeof orgs>;
|
||||
export type User = InferSelectModel<typeof users>;
|
||||
export type Site = InferSelectModel<typeof sites>;
|
||||
@@ -325,3 +337,4 @@ export type UserOrg = InferSelectModel<typeof userOrgs>;
|
||||
export type ResourceSession = InferSelectModel<typeof resourceSessions>;
|
||||
export type ResourcePincode = InferSelectModel<typeof resourcePincode>;
|
||||
export type ResourcePassword = InferSelectModel<typeof resourcePassword>;
|
||||
export type ResourceOtp = InferSelectModel<typeof resourceOtp>;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render } from "@react-email/components";
|
||||
import { render } from "@react-email/render";
|
||||
import { ReactElement } from "react";
|
||||
import emailClient from "@server/emails";
|
||||
import logger from "@server/logger";
|
||||
|
||||
@@ -33,9 +33,17 @@ export const SendInviteLink = ({
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{previewText}</Preview>
|
||||
<Tailwind>
|
||||
<Tailwind config={{
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: "#F97317"
|
||||
}
|
||||
}
|
||||
}
|
||||
}}>
|
||||
<Body className="font-sans">
|
||||
<Container className="bg-white border border-solid border-gray-200 p-6 max-w-lg mx-auto my-8">
|
||||
<Container className="bg-white border border-solid border-gray-200 p-6 max-w-lg mx-auto my-8 rounded-lg">
|
||||
<Heading className="text-2xl font-semibold text-gray-800 text-center">
|
||||
You're invited to join a Fossorial organization
|
||||
</Heading>
|
||||
@@ -58,7 +66,7 @@ export const SendInviteLink = ({
|
||||
<Section className="text-center my-6">
|
||||
<Button
|
||||
href={inviteLink}
|
||||
className="rounded-md bg-gray-600 px-[12px] py-[12px] text-center font-semibold text-white cursor-pointer"
|
||||
className="rounded-lg bg-primary px-[12px] py-[9px] text-center font-semibold text-white cursor-pointer"
|
||||
>
|
||||
Accept invitation to {orgName}
|
||||
</Button>
|
||||
|
||||
@@ -14,11 +14,13 @@ import * as React from "react";
|
||||
interface VerifyEmailProps {
|
||||
username?: string;
|
||||
verificationCode: string;
|
||||
verifyLink: string;
|
||||
}
|
||||
|
||||
export const VerifyEmail = ({
|
||||
username,
|
||||
verificationCode,
|
||||
verifyLink,
|
||||
}: VerifyEmailProps) => {
|
||||
const previewText = `Verify your email, ${username}`;
|
||||
|
||||
@@ -26,21 +28,34 @@ export const VerifyEmail = ({
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{previewText}</Preview>
|
||||
<Tailwind>
|
||||
<Tailwind
|
||||
config={{
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: "#F97317",
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Body className="font-sans">
|
||||
<Container className="bg-white border border-solid border-gray-200 p-6 max-w-lg mx-auto my-8">
|
||||
<Container className="bg-white border border-solid border-gray-200 p-6 max-w-lg mx-auto my-8 rounded-lg">
|
||||
<Heading className="text-2xl font-semibold text-gray-800 text-center">
|
||||
Verify Your Email
|
||||
Please verify your email
|
||||
</Heading>
|
||||
<Text className="text-base text-gray-700 mt-4">
|
||||
Hi {username || "there"},
|
||||
</Text>
|
||||
<Text className="text-base text-gray-700 mt-2">
|
||||
You’ve requested to verify your email. Please use
|
||||
the verification code below:
|
||||
You’ve requested to verify your email. Please{" "}
|
||||
<a href={verifyLink} className="text-primary">
|
||||
click here
|
||||
</a>{" "}
|
||||
to verify your email, then enter the following code:
|
||||
</Text>
|
||||
<Section className="text-center my-6">
|
||||
<Text className="inline-block bg-gray-100 text-xl font-bold text-gray-900 py-2 px-4 border border-gray-300 rounded-md">
|
||||
<Text className="inline-block bg-primary text-xl font-bold text-white py-2 px-4 border border-gray-300 rounded-xl">
|
||||
{verificationCode}
|
||||
</Text>
|
||||
</Section>
|
||||
@@ -59,3 +74,5 @@ export const VerifyEmail = ({
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
|
||||
export default VerifyEmail;
|
||||
|
||||
@@ -51,7 +51,7 @@ app.prepare().then(() => {
|
||||
externalServer.use(logIncomingMiddleware);
|
||||
externalServer.use(prefix, unauthenticated);
|
||||
externalServer.use(prefix, authenticated);
|
||||
externalServer.use(`${prefix}/ws`, wsRouter);
|
||||
// externalServer.use(`${prefix}/ws`, wsRouter);
|
||||
|
||||
externalServer.use(notFoundMiddleware);
|
||||
|
||||
@@ -68,7 +68,7 @@ app.prepare().then(() => {
|
||||
);
|
||||
});
|
||||
|
||||
handleWSUpgrade(httpServer);
|
||||
// handleWSUpgrade(httpServer);
|
||||
|
||||
externalServer.use(errorHandlerMiddleware);
|
||||
|
||||
|
||||
65
server/routers/auth/checkResourceSession.ts
Normal file
65
server/routers/auth/checkResourceSession.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import createHttpError from "http-errors";
|
||||
import { z } from "zod";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { response } from "@server/utils";
|
||||
import { validateSessionToken } from "@server/auth";
|
||||
import { validateResourceSessionToken } from "@server/auth/resource";
|
||||
|
||||
export const params = z.object({
|
||||
token: z.string(),
|
||||
resourceId: z.string().transform(Number).pipe(z.number().int().positive()),
|
||||
});
|
||||
|
||||
export type CheckResourceSessionParams = z.infer<typeof params>;
|
||||
|
||||
export type CheckResourceSessionResponse = {
|
||||
valid: boolean;
|
||||
};
|
||||
|
||||
export async function checkResourceSession(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
): Promise<any> {
|
||||
const parsedParams = params.safeParse(req.params);
|
||||
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const { token, resourceId } = parsedParams.data;
|
||||
|
||||
try {
|
||||
const { resourceSession } = await validateResourceSessionToken(
|
||||
token,
|
||||
resourceId,
|
||||
);
|
||||
|
||||
let valid = false;
|
||||
if (resourceSession) {
|
||||
valid = true;
|
||||
}
|
||||
|
||||
return response<CheckResourceSessionResponse>(res, {
|
||||
data: { valid },
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Checked validity",
|
||||
status: HttpCode.OK,
|
||||
});
|
||||
} catch (e) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Failed to reset password",
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -9,3 +9,4 @@ export * from "./requestEmailVerificationCode";
|
||||
export * from "./changePassword";
|
||||
export * from "./requestPasswordReset";
|
||||
export * from "./resetPassword";
|
||||
export * from "./checkResourceSession";
|
||||
|
||||
@@ -139,7 +139,7 @@ export async function login(
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Email verification code sent",
|
||||
status: HttpCode.ACCEPTED,
|
||||
status: HttpCode.OK,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -13,11 +13,18 @@ export async function sendEmailVerificationCode(
|
||||
): Promise<void> {
|
||||
const code = await generateEmailVerificationCode(userId, email);
|
||||
|
||||
await sendEmail(VerifyEmail({ username: email, verificationCode: code }), {
|
||||
to: email,
|
||||
from: config.email?.no_reply,
|
||||
subject: "Verify your email address",
|
||||
});
|
||||
await sendEmail(
|
||||
VerifyEmail({
|
||||
username: email,
|
||||
verificationCode: code,
|
||||
verifyLink: `${config.app.base_url}/auth/verify-email`,
|
||||
}),
|
||||
{
|
||||
to: email,
|
||||
from: config.email?.no_reply,
|
||||
subject: "Verify your email address",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function generateEmailVerificationCode(
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
resourcePassword,
|
||||
resourcePincode,
|
||||
resources,
|
||||
User,
|
||||
userOrgs,
|
||||
} from "@server/db/schema";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
@@ -19,10 +20,7 @@ import { Resource, roleResources, userResources } from "@server/db/schema";
|
||||
import logger from "@server/logger";
|
||||
|
||||
const verifyResourceSessionSchema = z.object({
|
||||
sessions: z.object({
|
||||
session: z.string().nullable(),
|
||||
resource_session: z.string().nullable(),
|
||||
}),
|
||||
sessions: z.record(z.string()).optional(),
|
||||
originalRequestURL: z.string().url(),
|
||||
scheme: z.string(),
|
||||
host: z.string(),
|
||||
@@ -98,13 +96,18 @@ export async function verifyResourceSession(
|
||||
|
||||
const redirectUrl = `${config.app.base_url}/auth/resource/${encodeURIComponent(resource.resourceId)}?redirect=${encodeURIComponent(originalRequestURL)}`;
|
||||
|
||||
if (sso && sessions.session) {
|
||||
const { session, user } = await validateSessionToken(
|
||||
sessions.session,
|
||||
);
|
||||
if (!sessions) {
|
||||
return notAllowed(res);
|
||||
}
|
||||
|
||||
const sessionToken = sessions[config.server.session_cookie_name];
|
||||
|
||||
// check for unified login
|
||||
if (sso && sessionToken) {
|
||||
const { session, user } = await validateSessionToken(sessionToken);
|
||||
if (session && user) {
|
||||
const isAllowed = await isUserAllowedToAccessResource(
|
||||
user.userId,
|
||||
user,
|
||||
resource,
|
||||
);
|
||||
|
||||
@@ -117,11 +120,17 @@ export async function verifyResourceSession(
|
||||
}
|
||||
}
|
||||
|
||||
if (password && sessions.resource_session) {
|
||||
const resourceSessionToken =
|
||||
sessions[
|
||||
`${config.server.resource_session_cookie_name}_${resource.resourceId}`
|
||||
];
|
||||
|
||||
if ((pincode || password) && resourceSessionToken) {
|
||||
const { resourceSession } = await validateResourceSessionToken(
|
||||
sessions.resource_session,
|
||||
resourceSessionToken,
|
||||
resource.resourceId,
|
||||
);
|
||||
|
||||
if (resourceSession) {
|
||||
if (
|
||||
pincode &&
|
||||
@@ -165,7 +174,7 @@ function notAllowed(res: Response, redirectUrl?: string) {
|
||||
error: false,
|
||||
message: "Access denied",
|
||||
status: HttpCode.OK,
|
||||
}
|
||||
};
|
||||
logger.debug(JSON.stringify(data));
|
||||
return response<VerifyUserResponse>(res, data);
|
||||
}
|
||||
@@ -177,21 +186,25 @@ function allowed(res: Response) {
|
||||
error: false,
|
||||
message: "Access allowed",
|
||||
status: HttpCode.OK,
|
||||
}
|
||||
};
|
||||
logger.debug(JSON.stringify(data));
|
||||
return response<VerifyUserResponse>(res, data);
|
||||
}
|
||||
|
||||
async function isUserAllowedToAccessResource(
|
||||
userId: string,
|
||||
user: User,
|
||||
resource: Resource,
|
||||
) {
|
||||
): Promise<boolean> {
|
||||
if (config.flags?.require_email_verification && !user.emailVerified) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const userOrgRole = await db
|
||||
.select()
|
||||
.from(userOrgs)
|
||||
.where(
|
||||
and(
|
||||
eq(userOrgs.userId, userId),
|
||||
eq(userOrgs.userId, user.userId),
|
||||
eq(userOrgs.orgId, resource.orgId),
|
||||
),
|
||||
)
|
||||
@@ -221,7 +234,7 @@ async function isUserAllowedToAccessResource(
|
||||
.from(userResources)
|
||||
.where(
|
||||
and(
|
||||
eq(userResources.userId, userId),
|
||||
eq(userResources.userId, user.userId),
|
||||
eq(userResources.resourceId, resource.resourceId),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Router } from "express";
|
||||
import * as gerbil from "@server/routers/gerbil";
|
||||
import * as badger from "@server/routers/badger";
|
||||
import * as traefik from "@server/routers/traefik";
|
||||
import * as auth from "@server/routers/auth";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
|
||||
// Root routes
|
||||
@@ -12,6 +13,10 @@ internalRouter.get("/", (_, res) => {
|
||||
});
|
||||
|
||||
internalRouter.get("/traefik-config", traefik.traefikConfigProvider);
|
||||
internalRouter.get(
|
||||
"/resource-session/:resourceId/:token",
|
||||
auth.checkResourceSession,
|
||||
);
|
||||
|
||||
// Gerbil routes
|
||||
const gerbilRouter = Router();
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
serializeResourceSessionCookie,
|
||||
} from "@server/auth/resource";
|
||||
import logger from "@server/logger";
|
||||
import config from "@server/config";
|
||||
|
||||
export const authWithPasswordBodySchema = z.object({
|
||||
password: z.string(),
|
||||
@@ -131,15 +132,15 @@ export async function authWithPassword(
|
||||
token,
|
||||
passwordId: definedPassword.passwordId,
|
||||
});
|
||||
// const secureCookie = resource.ssl;
|
||||
// const cookie = serializeResourceSessionCookie(
|
||||
// token,
|
||||
// resource.fullDomain,
|
||||
// secureCookie,
|
||||
// );
|
||||
// res.appendHeader("Set-Cookie", cookie);
|
||||
const cookieName = `${config.server.resource_session_cookie_name}_${resource.resourceId}`;
|
||||
const cookie = serializeResourceSessionCookie(
|
||||
cookieName,
|
||||
token,
|
||||
resource.fullDomain,
|
||||
);
|
||||
res.appendHeader("Set-Cookie", cookie);
|
||||
|
||||
// logger.debug(cookie); // remove after testing
|
||||
logger.debug(cookie); // remove after testing
|
||||
|
||||
return response<AuthWithPasswordResponse>(res, {
|
||||
data: {
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
serializeResourceSessionCookie,
|
||||
} from "@server/auth/resource";
|
||||
import logger from "@server/logger";
|
||||
import config from "@server/config";
|
||||
|
||||
export const authWithPincodeBodySchema = z.object({
|
||||
pincode: z.string(),
|
||||
@@ -127,15 +128,15 @@ export async function authWithPincode(
|
||||
token,
|
||||
pincodeId: definedPincode.pincodeId,
|
||||
});
|
||||
// const secureCookie = resource.ssl;
|
||||
// const cookie = serializeResourceSessionCookie(
|
||||
// token,
|
||||
// resource.fullDomain,
|
||||
// secureCookie,
|
||||
// );
|
||||
// res.appendHeader("Set-Cookie", cookie);
|
||||
const cookieName = `${config.server.resource_session_cookie_name}_${resource.resourceId}`;
|
||||
const cookie = serializeResourceSessionCookie(
|
||||
cookieName,
|
||||
token,
|
||||
resource.fullDomain,
|
||||
);
|
||||
res.appendHeader("Set-Cookie", cookie);
|
||||
|
||||
// logger.debug(cookie); // remove after testing
|
||||
logger.debug(cookie); // remove after testing
|
||||
|
||||
return response<AuthWithPincodeResponse>(res, {
|
||||
data: {
|
||||
|
||||
@@ -94,6 +94,7 @@ export async function createResource(
|
||||
orgId,
|
||||
name,
|
||||
subdomain,
|
||||
ssl: true,
|
||||
})
|
||||
.returning();
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
sites,
|
||||
userResources,
|
||||
roleResources,
|
||||
resourcePassword,
|
||||
resourcePincode,
|
||||
} from "@server/db/schema";
|
||||
import response from "@server/utils/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
@@ -46,39 +48,65 @@ const listResourcesSchema = z.object({
|
||||
function queryResources(
|
||||
accessibleResourceIds: number[],
|
||||
siteId?: number,
|
||||
orgId?: string
|
||||
orgId?: string,
|
||||
) {
|
||||
if (siteId) {
|
||||
return db
|
||||
.select({
|
||||
resourceId: resources.resourceId,
|
||||
name: resources.name,
|
||||
subdomain: resources.subdomain,
|
||||
fullDomain: resources.fullDomain,
|
||||
ssl: resources.ssl,
|
||||
siteName: sites.name,
|
||||
siteId: sites.niceId,
|
||||
passwordId: resourcePassword.passwordId,
|
||||
pincodeId: resourcePincode.pincodeId,
|
||||
sso: resources.sso,
|
||||
})
|
||||
.from(resources)
|
||||
.leftJoin(sites, eq(resources.siteId, sites.siteId))
|
||||
.leftJoin(
|
||||
resourcePassword,
|
||||
eq(resourcePassword.resourceId, resources.resourceId),
|
||||
)
|
||||
.leftJoin(
|
||||
resourcePincode,
|
||||
eq(resourcePincode.resourceId, resources.resourceId),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
inArray(resources.resourceId, accessibleResourceIds),
|
||||
eq(resources.siteId, siteId)
|
||||
)
|
||||
eq(resources.siteId, siteId),
|
||||
),
|
||||
);
|
||||
} else if (orgId) {
|
||||
return db
|
||||
.select({
|
||||
resourceId: resources.resourceId,
|
||||
name: resources.name,
|
||||
subdomain: resources.subdomain,
|
||||
ssl: resources.ssl,
|
||||
fullDomain: resources.fullDomain,
|
||||
siteName: sites.name,
|
||||
siteId: sites.niceId,
|
||||
passwordId: resourcePassword.passwordId,
|
||||
sso: resources.sso,
|
||||
pincodeId: resourcePincode.pincodeId,
|
||||
})
|
||||
.from(resources)
|
||||
.leftJoin(sites, eq(resources.siteId, sites.siteId))
|
||||
.leftJoin(
|
||||
resourcePassword,
|
||||
eq(resourcePassword.resourceId, resources.resourceId),
|
||||
)
|
||||
.leftJoin(
|
||||
resourcePincode,
|
||||
eq(resourcePincode.resourceId, resources.resourceId),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
inArray(resources.resourceId, accessibleResourceIds),
|
||||
eq(resources.orgId, orgId)
|
||||
)
|
||||
eq(resources.orgId, orgId),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -91,7 +119,7 @@ export type ListResourcesResponse = {
|
||||
export async function listResources(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
next: NextFunction,
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedQuery = listResourcesSchema.safeParse(req.query);
|
||||
@@ -99,8 +127,8 @@ export async function listResources(
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
parsedQuery.error.errors.map((e) => e.message).join(", ")
|
||||
)
|
||||
parsedQuery.error.errors.map((e) => e.message).join(", "),
|
||||
),
|
||||
);
|
||||
}
|
||||
const { limit, offset } = parsedQuery.data;
|
||||
@@ -110,8 +138,8 @@ export async function listResources(
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
parsedParams.error.errors.map((e) => e.message).join(", ")
|
||||
)
|
||||
parsedParams.error.errors.map((e) => e.message).join(", "),
|
||||
),
|
||||
);
|
||||
}
|
||||
const { siteId, orgId } = parsedParams.data;
|
||||
@@ -120,8 +148,8 @@ export async function listResources(
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"User does not have access to this organization"
|
||||
)
|
||||
"User does not have access to this organization",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -132,17 +160,17 @@ export async function listResources(
|
||||
.from(userResources)
|
||||
.fullJoin(
|
||||
roleResources,
|
||||
eq(userResources.resourceId, roleResources.resourceId)
|
||||
eq(userResources.resourceId, roleResources.resourceId),
|
||||
)
|
||||
.where(
|
||||
or(
|
||||
eq(userResources.userId, req.user!.userId),
|
||||
eq(roleResources.roleId, req.userOrgRoleId!)
|
||||
)
|
||||
eq(roleResources.roleId, req.userOrgRoleId!),
|
||||
),
|
||||
);
|
||||
|
||||
const accessibleResourceIds = accessibleResources.map(
|
||||
(resource) => resource.resourceId
|
||||
(resource) => resource.resourceId,
|
||||
);
|
||||
|
||||
let countQuery: any = db
|
||||
@@ -173,7 +201,10 @@ export async function listResources(
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"An error occurred",
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,14 +38,15 @@ function querySites(orgId: string, accessibleSiteIds: number[]) {
|
||||
megabytesIn: sites.megabytesIn,
|
||||
megabytesOut: sites.megabytesOut,
|
||||
orgName: orgs.name,
|
||||
type: sites.type,
|
||||
})
|
||||
.from(sites)
|
||||
.leftJoin(orgs, eq(sites.orgId, orgs.orgId))
|
||||
.where(
|
||||
and(
|
||||
inArray(sites.siteId, accessibleSiteIds),
|
||||
eq(sites.orgId, orgId)
|
||||
)
|
||||
eq(sites.orgId, orgId),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -57,7 +58,7 @@ export type ListSitesResponse = {
|
||||
export async function listSites(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
next: NextFunction,
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedQuery = listSitesSchema.safeParse(req.query);
|
||||
@@ -65,8 +66,8 @@ export async function listSites(
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedQuery.error)
|
||||
)
|
||||
fromError(parsedQuery.error),
|
||||
),
|
||||
);
|
||||
}
|
||||
const { limit, offset } = parsedQuery.data;
|
||||
@@ -76,8 +77,8 @@ export async function listSites(
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error)
|
||||
)
|
||||
fromError(parsedParams.error),
|
||||
),
|
||||
);
|
||||
}
|
||||
const { orgId } = parsedParams.data;
|
||||
@@ -86,8 +87,8 @@ export async function listSites(
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"User does not have access to this organization"
|
||||
)
|
||||
"User does not have access to this organization",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -100,8 +101,8 @@ export async function listSites(
|
||||
.where(
|
||||
or(
|
||||
eq(userSites.userId, req.user!.userId),
|
||||
eq(roleSites.roleId, req.userOrgRoleId!)
|
||||
)
|
||||
eq(roleSites.roleId, req.userOrgRoleId!),
|
||||
),
|
||||
);
|
||||
|
||||
const accessibleSiteIds = accessibleSites.map((site) => site.siteId);
|
||||
@@ -113,8 +114,8 @@ export async function listSites(
|
||||
.where(
|
||||
and(
|
||||
inArray(sites.siteId, accessibleSiteIds),
|
||||
eq(sites.orgId, orgId)
|
||||
)
|
||||
eq(sites.orgId, orgId),
|
||||
),
|
||||
);
|
||||
|
||||
const sitesList = await baseQuery.limit(limit).offset(offset);
|
||||
@@ -137,7 +138,10 @@ export async function listSites(
|
||||
});
|
||||
} catch (error) {
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"An error occurred",
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,10 @@ export async function traefikConfigProvider(
|
||||
schema.orgs,
|
||||
eq(schema.resources.orgId, schema.orgs.orgId),
|
||||
)
|
||||
.innerJoin(
|
||||
schema.sites,
|
||||
eq(schema.sites.siteId, schema.resources.siteId),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.targets.enabled, true),
|
||||
@@ -51,11 +55,9 @@ export async function traefikConfigProvider(
|
||||
`http://${config.server.internal_hostname}:${config.server.internal_port}`,
|
||||
).href,
|
||||
resourceSessionCookieName:
|
||||
config.badger.resource_session_cookie_name,
|
||||
config.server.resource_session_cookie_name,
|
||||
userSessionCookieName:
|
||||
config.server.session_cookie_name,
|
||||
sessionQueryParameter:
|
||||
config.badger.session_query_parameter,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -70,6 +72,7 @@ export async function traefikConfigProvider(
|
||||
for (const item of all) {
|
||||
const target = item.targets;
|
||||
const resource = item.resources;
|
||||
const site = item.sites;
|
||||
const org = item.orgs;
|
||||
|
||||
const routerName = `${target.targetId}-router`;
|
||||
@@ -112,7 +115,7 @@ export async function traefikConfigProvider(
|
||||
? config.traefik.https_entrypoint
|
||||
: config.traefik.http_entrypoint,
|
||||
],
|
||||
middlewares: resource.ssl ? [badgerMiddlewareName] : [],
|
||||
middlewares: [badgerMiddlewareName],
|
||||
service: serviceName,
|
||||
rule: `Host(\`${fullDomain}\`)`,
|
||||
...(resource.ssl ? { tls } : {}),
|
||||
@@ -128,15 +131,28 @@ export async function traefikConfigProvider(
|
||||
};
|
||||
}
|
||||
|
||||
http.services![serviceName] = {
|
||||
loadBalancer: {
|
||||
servers: [
|
||||
{
|
||||
url: `${target.method}://${target.ip}:${target.port}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
if (site.type === "newt") {
|
||||
const ip = site.subnet.split("/")[0];
|
||||
http.services![serviceName] = {
|
||||
loadBalancer: {
|
||||
servers: [
|
||||
{
|
||||
url: `${target.method}://${ip}:${target.internalPort}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
} else if (site.type === "wireguard") {
|
||||
http.services![serviceName] = {
|
||||
loadBalancer: {
|
||||
servers: [
|
||||
{
|
||||
url: `${target.method}://${target.ip}:${target.port}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return res.status(HttpCode.OK).json({ http });
|
||||
|
||||
@@ -86,7 +86,6 @@ export async function inviteUser(
|
||||
|
||||
inviteTracker[email].timestamps.push(currentTime);
|
||||
|
||||
logger.debug("here0")
|
||||
const org = await db
|
||||
.select()
|
||||
.from(orgs)
|
||||
@@ -98,7 +97,6 @@ export async function inviteUser(
|
||||
);
|
||||
}
|
||||
|
||||
logger.debug("here1")
|
||||
const existingUser = await db
|
||||
.select()
|
||||
.from(users)
|
||||
@@ -114,7 +112,6 @@ export async function inviteUser(
|
||||
);
|
||||
}
|
||||
|
||||
logger.debug("here2")
|
||||
const inviteId = generateRandomString(
|
||||
10,
|
||||
alphabet("a-z", "A-Z", "0-9"),
|
||||
@@ -124,7 +121,6 @@ export async function inviteUser(
|
||||
|
||||
const tokenHash = await hashPassword(token);
|
||||
|
||||
logger.debug("here3")
|
||||
// delete any existing invites for this email
|
||||
await db
|
||||
.delete(userInvites)
|
||||
@@ -133,7 +129,6 @@ export async function inviteUser(
|
||||
)
|
||||
.execute();
|
||||
|
||||
logger.debug("here4")
|
||||
await db.insert(userInvites).values({
|
||||
inviteId,
|
||||
orgId,
|
||||
@@ -145,23 +140,21 @@ export async function inviteUser(
|
||||
|
||||
const inviteLink = `${config.app.base_url}/invite?token=${inviteId}-${token}`;
|
||||
|
||||
logger.debug("here5")
|
||||
// await sendEmail(
|
||||
// SendInviteLink({
|
||||
// email,
|
||||
// inviteLink,
|
||||
// expiresInDays: (validHours / 24).toString(),
|
||||
// orgName: org[0].name || orgId,
|
||||
// inviterName: req.user?.email,
|
||||
// }),
|
||||
// {
|
||||
// to: email,
|
||||
// from: config.email?.no_reply,
|
||||
// subject: "You're invited to join a Fossorial organization",
|
||||
// },
|
||||
// );
|
||||
await sendEmail(
|
||||
SendInviteLink({
|
||||
email,
|
||||
inviteLink,
|
||||
expiresInDays: (validHours / 24).toString(),
|
||||
orgName: org[0].name || orgId,
|
||||
inviterName: req.user?.email,
|
||||
}),
|
||||
{
|
||||
to: email,
|
||||
from: config.email?.no_reply,
|
||||
subject: "You're invited to join a Fossorial organization",
|
||||
},
|
||||
);
|
||||
|
||||
logger.debug("here6")
|
||||
return response<InviteUserResponse>(res, {
|
||||
data: {
|
||||
inviteLink,
|
||||
|
||||
@@ -22,4 +22,12 @@ export const internal = axios.create({
|
||||
},
|
||||
});
|
||||
|
||||
export const priv = axios.create({
|
||||
baseURL: `http://localhost:${process.env.SERVER_INTERNAL_PORT}/api/v1`,
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
export default api;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { internal } from "@app/api";
|
||||
import { authCookieHeader } from "@app/api/cookies";
|
||||
import { verifySession } from "@app/lib/auth/verifySession";
|
||||
import { GetOrgResponse } from "@server/routers/org";
|
||||
import { GetOrgUserResponse } from "@server/routers/user";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { redirect } from "next/navigation";
|
||||
import { cache } from "react";
|
||||
@@ -17,6 +19,25 @@ export default async function OrgLayout(props: {
|
||||
redirect(`/`);
|
||||
}
|
||||
|
||||
const getUser = cache(verifySession);
|
||||
const user = await getUser();
|
||||
|
||||
if (!user) {
|
||||
redirect(`/?redirect=/${orgId}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const getOrgUser = cache(() =>
|
||||
internal.get<AxiosResponse<GetOrgUserResponse>>(
|
||||
`/org/${orgId}/user/${user.userId}`,
|
||||
cookie,
|
||||
),
|
||||
);
|
||||
const orgUser = await getOrgUser();
|
||||
} catch {
|
||||
redirect(`/`);
|
||||
}
|
||||
|
||||
try {
|
||||
const getOrg = cache(() =>
|
||||
internal.get<AxiosResponse<GetOrgResponse>>(
|
||||
|
||||
@@ -14,27 +14,6 @@ export default async function OrgPage(props: OrgPageProps) {
|
||||
const params = await props.params;
|
||||
const orgId = params.orgId;
|
||||
|
||||
const getUser = cache(verifySession);
|
||||
const user = await getUser();
|
||||
|
||||
if (!user) {
|
||||
redirect("/auth/login");
|
||||
}
|
||||
|
||||
const cookie = await authCookieHeader();
|
||||
|
||||
try {
|
||||
const getOrgUser = cache(() =>
|
||||
internal.get<AxiosResponse<GetOrgUserResponse>>(
|
||||
`/org/${orgId}/user/${user.userId}`,
|
||||
cookie
|
||||
)
|
||||
);
|
||||
const orgUser = await getOrgUser();
|
||||
} catch {
|
||||
redirect(`/`);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<p>Welcome to {orgId} dashboard</p>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import { SidebarSettings } from "@app/components/SidebarSettings";
|
||||
|
||||
type AccessPageHeaderAndNavProps = {
|
||||
@@ -22,16 +23,12 @@ export default function AccessPageHeaderAndNav({
|
||||
|
||||
return (
|
||||
<>
|
||||
{" "}
|
||||
<div className="space-y-0.5 select-none mb-6">
|
||||
<h2 className="text-2xl font-bold tracking-tight">
|
||||
Users & Roles
|
||||
</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Invite users and add them to roles to manage access to your
|
||||
organization
|
||||
</p>
|
||||
</div>
|
||||
<SettingsSectionTitle
|
||||
title="Manage Users & Roles"
|
||||
description="Invite users and add them to roles to manage access to your
|
||||
organization"
|
||||
/>
|
||||
|
||||
<SidebarSettings sidebarNavItems={sidebarNavItems}>
|
||||
{children}
|
||||
</SidebarSettings>
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { useState } from "react";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { Plus } from "lucide-react";
|
||||
import { Plus, Search } from "lucide-react";
|
||||
import { DataTablePagination } from "@app/components/DataTablePagination";
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
@@ -61,19 +61,23 @@ export function RolesDataTable<TData, TValue>({
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between pb-4">
|
||||
<Input
|
||||
placeholder="Search roles"
|
||||
value={
|
||||
(table.getColumn("name")?.getFilterValue() as string) ??
|
||||
""
|
||||
}
|
||||
onChange={(event) =>
|
||||
table
|
||||
.getColumn("name")
|
||||
?.setFilterValue(event.target.value)
|
||||
}
|
||||
className="max-w-sm mr-2"
|
||||
/>
|
||||
<div className="flex items-center max-w-sm mr-2 w-full relative">
|
||||
<Input
|
||||
placeholder="Search roles"
|
||||
value={
|
||||
(table
|
||||
.getColumn("name")
|
||||
?.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>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (addRole) {
|
||||
@@ -84,7 +88,7 @@ export function RolesDataTable<TData, TValue>({
|
||||
<Plus className="mr-2 h-4 w-4" /> Add Role
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<div className="border rounded-md">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
@@ -97,7 +101,7 @@ export function RolesDataTable<TData, TValue>({
|
||||
: flexRender(
|
||||
header.column.columnDef
|
||||
.header,
|
||||
header.getContext()
|
||||
header.getContext(),
|
||||
)}
|
||||
</TableHead>
|
||||
);
|
||||
@@ -118,7 +122,7 @@ export function RolesDataTable<TData, TValue>({
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
cell.getContext(),
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
|
||||
@@ -65,6 +65,14 @@ export default function UsersTable({ roles: r }: RolesTableProps) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-end">
|
||||
{roleRow.isAdmin && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-8 w-8 p-0 opacity-0 cursor-default"
|
||||
>
|
||||
Placeholder
|
||||
</Button>
|
||||
)}
|
||||
{!roleRow.isAdmin && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -81,7 +89,7 @@ export default function UsersTable({ roles: r }: RolesTableProps) {
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem>
|
||||
<button
|
||||
className="text-red-600 hover:text-red-800"
|
||||
className="text-red-500"
|
||||
onClick={() => {
|
||||
setIsDeleteModalOpen(true);
|
||||
setUserToRemove(roleRow);
|
||||
@@ -117,7 +125,9 @@ export default function UsersTable({ roles: r }: RolesTableProps) {
|
||||
roleToDelete={roleToRemove}
|
||||
afterDelete={() => {
|
||||
setRoles((prev) =>
|
||||
prev.filter((r) => r.roleId !== roleToRemove.roleId)
|
||||
prev.filter(
|
||||
(r) => r.roleId !== roleToRemove.roleId,
|
||||
),
|
||||
);
|
||||
setUserToRemove(null);
|
||||
}}
|
||||
|
||||
@@ -23,7 +23,7 @@ import { Button } from "@app/components/ui/button";
|
||||
import { useState } from "react";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { DataTablePagination } from "../../../../../../components/DataTablePagination";
|
||||
import { Plus } from "lucide-react";
|
||||
import { Plus, Search } from "lucide-react";
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
@@ -61,20 +61,23 @@ export function UsersDataTable<TData, TValue>({
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between pb-4">
|
||||
<Input
|
||||
placeholder="Search users"
|
||||
value={
|
||||
(table
|
||||
.getColumn("email")
|
||||
?.getFilterValue() as string) ?? ""
|
||||
}
|
||||
onChange={(event) =>
|
||||
table
|
||||
.getColumn("email")
|
||||
?.setFilterValue(event.target.value)
|
||||
}
|
||||
className="max-w-sm mr-2"
|
||||
/>
|
||||
<div className="flex items-center max-w-sm mr-2 w-full relative">
|
||||
<Input
|
||||
placeholder="Search users"
|
||||
value={
|
||||
(table
|
||||
.getColumn("email")
|
||||
?.getFilterValue() as string) ?? ""
|
||||
}
|
||||
onChange={(event) =>
|
||||
table
|
||||
.getColumn("email")
|
||||
?.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>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (inviteUser) {
|
||||
@@ -85,7 +88,7 @@ export function UsersDataTable<TData, TValue>({
|
||||
<Plus className="mr-2 h-4 w-4" /> Invite User
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<div className="border rounded-md">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
@@ -98,7 +101,7 @@ export function UsersDataTable<TData, TValue>({
|
||||
: flexRender(
|
||||
header.column.columnDef
|
||||
.header,
|
||||
header.getContext()
|
||||
header.getContext(),
|
||||
)}
|
||||
</TableHead>
|
||||
);
|
||||
@@ -119,7 +122,7 @@ export function UsersDataTable<TData, TValue>({
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
cell.getContext(),
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
|
||||
@@ -99,7 +99,9 @@ export default function UsersTable({ users: u }: UsersTableProps) {
|
||||
|
||||
return (
|
||||
<div className="flex flex-row items-center gap-1">
|
||||
{userRow.isOwner && <Crown className="w-4 h-4" />}
|
||||
{userRow.isOwner && (
|
||||
<Crown className="w-4 h-4 text-yellow-600" />
|
||||
)}
|
||||
<span>{userRow.role}</span>
|
||||
</div>
|
||||
);
|
||||
@@ -113,6 +115,14 @@ export default function UsersTable({ users: u }: UsersTableProps) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-end">
|
||||
{userRow.isOwner && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="opacity-0 cursor-default"
|
||||
>
|
||||
Placeholder
|
||||
</Button>
|
||||
)}
|
||||
{!userRow.isOwner && (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
@@ -138,13 +148,13 @@ export default function UsersTable({ users: u }: UsersTableProps) {
|
||||
{userRow.email !== user?.email && (
|
||||
<DropdownMenuItem>
|
||||
<button
|
||||
className="text-red-600 hover:text-red-800"
|
||||
className="text-red-500"
|
||||
onClick={() => {
|
||||
setIsDeleteModalOpen(
|
||||
true
|
||||
true,
|
||||
);
|
||||
setSelectedUser(
|
||||
userRow
|
||||
userRow,
|
||||
);
|
||||
}}
|
||||
>
|
||||
@@ -154,18 +164,17 @@ export default function UsersTable({ users: u }: UsersTableProps) {
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button
|
||||
variant={"gray"}
|
||||
className="ml-2"
|
||||
onClick={() =>
|
||||
router.push(
|
||||
`/${org?.org.orgId}/settings/access/users/${userRow.id}`
|
||||
)
|
||||
}
|
||||
<Link
|
||||
href={`/${org?.org.orgId}/settings/access/users/${userRow.id}`}
|
||||
>
|
||||
Manage{" "}
|
||||
<ArrowRight className="ml-2 w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={"gray"}
|
||||
className="ml-2"
|
||||
>
|
||||
Manage
|
||||
<ArrowRight className="ml-2 w-4 h-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -185,7 +194,7 @@ export default function UsersTable({ users: u }: UsersTableProps) {
|
||||
title: "Failed to remove user",
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
"An error occurred while removing the user."
|
||||
"An error occurred while removing the user.",
|
||||
),
|
||||
});
|
||||
});
|
||||
@@ -198,7 +207,7 @@ export default function UsersTable({ users: u }: UsersTableProps) {
|
||||
});
|
||||
|
||||
setUsers((prev) =>
|
||||
prev.filter((u) => u.id !== selectedUser?.id)
|
||||
prev.filter((u) => u.id !== selectedUser?.id),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,7 +149,7 @@ export default function Header({ email, orgId, name, orgs }: HeaderProps) {
|
||||
size="lg"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="w-full md:w-[200px] h-12 px-3 py-4 bg-neutral"
|
||||
className="w-full md:w-[200px] h-12 px-3 py-4 bg-neutral hover:bg-neutral"
|
||||
>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div className="flex flex-col items-start">
|
||||
@@ -202,29 +202,6 @@ export default function Header({ email, orgId, name, orgs }: HeaderProps) {
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* <Select
|
||||
defaultValue={orgId}
|
||||
onValueChange={(val) => {
|
||||
router.push(`/${val}/settings`);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[100px] md:w-[180px]">
|
||||
<SelectValue placeholder="Select an org" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{orgs.map((org) => (
|
||||
<SelectItem
|
||||
value={org.name}
|
||||
key={org.orgId}
|
||||
>
|
||||
{org.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select> */}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -26,7 +26,7 @@ export default async function GeneralSettingsPage({
|
||||
const user = await getUser();
|
||||
|
||||
if (!user) {
|
||||
redirect("/auth/login");
|
||||
redirect(`/?redirect=/${orgId}/settings/general`);
|
||||
}
|
||||
|
||||
let orgUser = null;
|
||||
@@ -34,8 +34,8 @@ export default async function GeneralSettingsPage({
|
||||
const getOrgUser = cache(async () =>
|
||||
internal.get<AxiosResponse<GetOrgUserResponse>>(
|
||||
`/org/${orgId}/user/${user.userId}`,
|
||||
await authCookieHeader()
|
||||
)
|
||||
await authCookieHeader(),
|
||||
),
|
||||
);
|
||||
const res = await getOrgUser();
|
||||
orgUser = res.data.data;
|
||||
@@ -48,8 +48,8 @@ export default async function GeneralSettingsPage({
|
||||
const getOrg = cache(async () =>
|
||||
internal.get<AxiosResponse<GetOrgResponse>>(
|
||||
`/org/${orgId}`,
|
||||
await authCookieHeader()
|
||||
)
|
||||
await authCookieHeader(),
|
||||
),
|
||||
);
|
||||
const res = await getOrg();
|
||||
org = res.data.data;
|
||||
|
||||
@@ -55,7 +55,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
|
||||
const user = await getUser();
|
||||
|
||||
if (!user) {
|
||||
redirect("/auth/login");
|
||||
redirect(`/?redirect=/${params.orgId}/`);
|
||||
}
|
||||
|
||||
const cookie = await authCookieHeader();
|
||||
@@ -64,8 +64,8 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
|
||||
const getOrgUser = cache(() =>
|
||||
internal.get<AxiosResponse<GetOrgUserResponse>>(
|
||||
`/org/${params.orgId}/user/${user.userId}`,
|
||||
cookie
|
||||
)
|
||||
cookie,
|
||||
),
|
||||
);
|
||||
const orgUser = await getOrgUser();
|
||||
|
||||
@@ -79,7 +79,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
|
||||
let orgs: ListOrgsResponse["orgs"] = [];
|
||||
try {
|
||||
const getOrgs = cache(() =>
|
||||
internal.get<AxiosResponse<ListOrgsResponse>>(`/orgs`, cookie)
|
||||
internal.get<AxiosResponse<ListOrgsResponse>>(`/orgs`, cookie),
|
||||
);
|
||||
const res = await getOrgs();
|
||||
if (res && res.data.data.orgs) {
|
||||
@@ -91,7 +91,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-full border-b bg-neutral-100 dark:bg-neutral-900 mb-6 select-none sm:px-0 px-3 pt-3">
|
||||
<div className="w-full border-b bg-neutral-100 dark:bg-neutral-800 mb-6 select-none sm:px-0 px-3 pt-3">
|
||||
<div className="container mx-auto flex flex-col content-between gap-4 ">
|
||||
<Header
|
||||
email={user.email}
|
||||
|
||||
@@ -305,11 +305,11 @@ export default function ResourceAuthenticationPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="space-y-12">
|
||||
<div className="space-y-12 lg:max-w-2xl">
|
||||
<section className="space-y-8">
|
||||
<SettingsSectionTitle
|
||||
title="Users & Roles"
|
||||
description="Configure which users can access this resource (only applicable if SSO enabled)"
|
||||
description="Configure which users and roles can visit this resource"
|
||||
size="1xl"
|
||||
/>
|
||||
|
||||
@@ -320,11 +320,13 @@ export default function ResourceAuthenticationPage() {
|
||||
defaultChecked={resource.sso}
|
||||
onCheckedChange={(val) => setSsoEnabled(val)}
|
||||
/>
|
||||
<Label htmlFor="sso-toggle">Allow SSO</Label>
|
||||
<Label htmlFor="sso-toggle">
|
||||
Allow Unified Login
|
||||
</Label>
|
||||
</div>
|
||||
<span className="text-muted-foreground text-sm">
|
||||
Existing users will only have to login once for all
|
||||
resources that have SSO enabled.
|
||||
resources that have this enabled.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -460,7 +462,7 @@ export default function ResourceAuthenticationPage() {
|
||||
|
||||
<Separator />
|
||||
|
||||
<section className="space-y-8 lg:max-w-2xl">
|
||||
<section className="space-y-8">
|
||||
<SettingsSectionTitle
|
||||
title="Authentication Methods"
|
||||
description="Allow anyone to access the resource via the below methods"
|
||||
|
||||
@@ -233,6 +233,24 @@ export default function ReverseProxyTargets(props: {
|
||||
}
|
||||
|
||||
const columns: ColumnDef<LocalTarget>[] = [
|
||||
{
|
||||
accessorKey: "method",
|
||||
header: "Method",
|
||||
cell: ({ row }) => (
|
||||
<Select
|
||||
defaultValue={row.original.method}
|
||||
onValueChange={(value) =>
|
||||
updateTarget(row.original.targetId, { method: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger>{row.original.method}</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="http">http</SelectItem>
|
||||
<SelectItem value="https">https</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "ip",
|
||||
header: "IP Address",
|
||||
@@ -262,24 +280,6 @@ export default function ReverseProxyTargets(props: {
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "method",
|
||||
header: "Method",
|
||||
cell: ({ row }) => (
|
||||
<Select
|
||||
defaultValue={row.original.method}
|
||||
onValueChange={(value) =>
|
||||
updateTarget(row.original.targetId, { method: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger>{row.original.method}</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="http">http</SelectItem>
|
||||
<SelectItem value="https">https</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
),
|
||||
},
|
||||
// {
|
||||
// accessorKey: "protocol",
|
||||
// header: "Protocol",
|
||||
@@ -368,7 +368,7 @@ export default function ReverseProxyTargets(props: {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<hr />
|
||||
<hr className="lg:max-w-2xl" />
|
||||
|
||||
<section className="space-y-8">
|
||||
<SettingsSectionTitle
|
||||
@@ -386,25 +386,6 @@ export default function ReverseProxyTargets(props: {
|
||||
className="space-y-8"
|
||||
>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||
<FormField
|
||||
control={addTargetForm.control}
|
||||
name="ip"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
IP Address
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input id="ip" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Enter the IP address of the
|
||||
target.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={addTargetForm.control}
|
||||
name="method"
|
||||
@@ -444,6 +425,25 @@ export default function ReverseProxyTargets(props: {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={addTargetForm.control}
|
||||
name="ip"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
IP Address
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input id="ip" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Enter the IP address of the
|
||||
target.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={addTargetForm.control}
|
||||
name="port"
|
||||
|
||||
@@ -34,7 +34,7 @@ import { ListSitesResponse } from "@server/routers/site";
|
||||
import { useEffect, useState } from "react";
|
||||
import { AxiosResponse } from "axios";
|
||||
import api from "@app/api";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { GetResourceAuthInfoResponse } from "@server/routers/resource";
|
||||
import { useToast } from "@app/hooks/useToast";
|
||||
@@ -57,6 +57,7 @@ export default function GeneralForm() {
|
||||
const { toast } = useToast();
|
||||
const { resource, updateResource } = useResourceContext();
|
||||
const { org } = useOrgContext();
|
||||
const router = useRouter();
|
||||
|
||||
const orgId = params.orgId;
|
||||
|
||||
@@ -112,6 +113,8 @@ export default function GeneralForm() {
|
||||
});
|
||||
|
||||
updateResource({ name: data.name, subdomain: data.subdomain });
|
||||
|
||||
router.refresh();
|
||||
})
|
||||
.finally(() => setSaveLoading(false));
|
||||
}
|
||||
|
||||
@@ -59,11 +59,6 @@ const accountFormSchema = z.object({
|
||||
|
||||
type AccountFormValues = z.infer<typeof accountFormSchema>;
|
||||
|
||||
const defaultValues: Partial<AccountFormValues> = {
|
||||
subdomain: "",
|
||||
name: "My Resource",
|
||||
};
|
||||
|
||||
type CreateResourceFormProps = {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
@@ -88,7 +83,10 @@ export default function CreateResourceForm({
|
||||
|
||||
const form = useForm<AccountFormValues>({
|
||||
resolver: zodResolver(accountFormSchema),
|
||||
defaultValues,
|
||||
defaultValues: {
|
||||
subdomain: "",
|
||||
name: "My Resource",
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -98,9 +96,13 @@ export default function CreateResourceForm({
|
||||
|
||||
const fetchSites = async () => {
|
||||
const res = await api.get<AxiosResponse<ListSitesResponse>>(
|
||||
`/org/${orgId}/sites/`
|
||||
`/org/${orgId}/sites/`,
|
||||
);
|
||||
setSites(res.data.data.sites);
|
||||
|
||||
if (res.data.data.sites.length > 0) {
|
||||
form.setValue("siteId", res.data.data.sites[0].siteId);
|
||||
}
|
||||
};
|
||||
|
||||
fetchSites();
|
||||
@@ -116,7 +118,7 @@ export default function CreateResourceForm({
|
||||
name: data.name,
|
||||
subdomain: data.subdomain,
|
||||
// subdomain: data.subdomain,
|
||||
}
|
||||
},
|
||||
)
|
||||
.catch((e) => {
|
||||
toast({
|
||||
@@ -124,7 +126,7 @@ export default function CreateResourceForm({
|
||||
title: "Error creating resource",
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
"An error occurred when creating the resource"
|
||||
"An error occurred when creating the resource",
|
||||
),
|
||||
});
|
||||
});
|
||||
@@ -196,7 +198,7 @@ export default function CreateResourceForm({
|
||||
onChange={(value) =>
|
||||
form.setValue(
|
||||
"subdomain",
|
||||
value
|
||||
value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
@@ -225,14 +227,14 @@ export default function CreateResourceForm({
|
||||
className={cn(
|
||||
"w-[350px] justify-between",
|
||||
!field.value &&
|
||||
"text-muted-foreground"
|
||||
"text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{field.value
|
||||
? sites.find(
|
||||
(site) =>
|
||||
site.siteId ===
|
||||
field.value
|
||||
field.value,
|
||||
)?.name
|
||||
: "Select site"}
|
||||
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
@@ -259,7 +261,7 @@ export default function CreateResourceForm({
|
||||
onSelect={() => {
|
||||
form.setValue(
|
||||
"siteId",
|
||||
site.siteId
|
||||
site.siteId,
|
||||
);
|
||||
}}
|
||||
>
|
||||
@@ -269,14 +271,14 @@ export default function CreateResourceForm({
|
||||
site.siteId ===
|
||||
field.value
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
{
|
||||
site.name
|
||||
}
|
||||
</CommandItem>
|
||||
)
|
||||
),
|
||||
)}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
|
||||
@@ -24,7 +24,7 @@ import { Button } from "@app/components/ui/button";
|
||||
import { useState } from "react";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { DataTablePagination } from "@app/components/DataTablePagination";
|
||||
import { Plus } from "lucide-react";
|
||||
import { Plus, Search } from "lucide-react";
|
||||
|
||||
interface ResourcesDataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
@@ -62,19 +62,23 @@ export function ResourcesDataTable<TData, TValue>({
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between pb-4">
|
||||
<Input
|
||||
placeholder="Search resources"
|
||||
value={
|
||||
(table.getColumn("name")?.getFilterValue() as string) ??
|
||||
""
|
||||
}
|
||||
onChange={(event) =>
|
||||
table
|
||||
.getColumn("name")
|
||||
?.setFilterValue(event.target.value)
|
||||
}
|
||||
className="max-w-sm mr-2"
|
||||
/>
|
||||
<div className="flex items-center max-w-sm mr-2 w-full relative">
|
||||
<Input
|
||||
placeholder="Search resources"
|
||||
value={
|
||||
(table
|
||||
.getColumn("name")
|
||||
?.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>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (addResource) {
|
||||
@@ -85,7 +89,7 @@ export function ResourcesDataTable<TData, TValue>({
|
||||
<Plus className="mr-2 h-4 w-4" /> Add Resource
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<div className="border rounded-md">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
@@ -98,7 +102,7 @@ export function ResourcesDataTable<TData, TValue>({
|
||||
: flexRender(
|
||||
header.column.columnDef
|
||||
.header,
|
||||
header.getContext()
|
||||
header.getContext(),
|
||||
)}
|
||||
</TableHead>
|
||||
);
|
||||
@@ -119,7 +123,7 @@ export function ResourcesDataTable<TData, TValue>({
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
cell.getContext(),
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
|
||||
@@ -9,7 +9,16 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@app/components/ui/dropdown-menu";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { ArrowRight, ArrowUpDown, MoreHorizontal } from "lucide-react";
|
||||
import {
|
||||
Copy,
|
||||
ArrowRight,
|
||||
ArrowUpDown,
|
||||
MoreHorizontal,
|
||||
Check,
|
||||
ArrowUpRight,
|
||||
ShieldOff,
|
||||
ShieldCheck,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import api from "@app/api";
|
||||
@@ -26,6 +35,8 @@ export type ResourceRow = {
|
||||
orgId: string;
|
||||
domain: string;
|
||||
site: string;
|
||||
siteId: string;
|
||||
hasAuth: boolean;
|
||||
};
|
||||
|
||||
type ResourcesTableProps = {
|
||||
@@ -91,10 +102,111 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const resourceRow = row.original;
|
||||
return (
|
||||
<Button variant="outline">
|
||||
<Link
|
||||
href={`/${resourceRow.orgId}/settings/sites/${resourceRow.siteId}`}
|
||||
>
|
||||
{resourceRow.site}
|
||||
</Link>
|
||||
<ArrowUpRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "domain",
|
||||
header: "Domain",
|
||||
header: "Full URL",
|
||||
cell: ({ row }) => {
|
||||
const resourceRow = row.original;
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<Link
|
||||
href={resourceRow.domain}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:underline mr-2"
|
||||
>
|
||||
{resourceRow.domain}
|
||||
</Link>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(
|
||||
resourceRow.domain,
|
||||
);
|
||||
const originalIcon = document.querySelector(
|
||||
`#icon-${resourceRow.id}`,
|
||||
);
|
||||
if (originalIcon) {
|
||||
originalIcon.classList.add("hidden");
|
||||
}
|
||||
const checkIcon = document.querySelector(
|
||||
`#check-icon-${resourceRow.id}`,
|
||||
);
|
||||
if (checkIcon) {
|
||||
checkIcon.classList.remove("hidden");
|
||||
setTimeout(() => {
|
||||
checkIcon.classList.add("hidden");
|
||||
if (originalIcon) {
|
||||
originalIcon.classList.remove(
|
||||
"hidden",
|
||||
);
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Copy
|
||||
id={`icon-${resourceRow.id}`}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
<Check
|
||||
id={`check-icon-${resourceRow.id}`}
|
||||
className="hidden text-green-500 h-4 w-4"
|
||||
/>
|
||||
<span className="sr-only">Copy domain</span>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "hasAuth",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Authentication
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const resourceRow = row.original;
|
||||
return (
|
||||
<div>
|
||||
{resourceRow.hasAuth ? (
|
||||
<span className="text-green-500 flex items-center space-x-2">
|
||||
<ShieldCheck className="w-4 h-4" />
|
||||
<span>Protected</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-yellow-500 flex items-center space-x-2">
|
||||
<ShieldOff className="w-4 h-4" />
|
||||
<span>Not Protected</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
@@ -130,28 +242,25 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedResource(
|
||||
resourceRow
|
||||
resourceRow,
|
||||
);
|
||||
setIsDeleteModalOpen(true);
|
||||
}}
|
||||
className="text-red-600 hover:text-red-800 hover:underline cursor-pointer"
|
||||
className="text-red-500"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button
|
||||
variant={"gray"}
|
||||
className="ml-2"
|
||||
onClick={() =>
|
||||
router.push(
|
||||
`/${resourceRow.orgId}/settings/resources/${resourceRow.id}`
|
||||
)
|
||||
}
|
||||
<Link
|
||||
href={`/${resourceRow.orgId}/settings/resources/${resourceRow.id}`}
|
||||
>
|
||||
Edit <ArrowRight className="ml-2 w-4 h-4" />
|
||||
</Button>
|
||||
<Button variant={"gray"} className="ml-2">
|
||||
Edit
|
||||
<ArrowRight className="ml-2 w-4 h-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -19,7 +19,7 @@ export default async function ResourcesPage(props: ResourcesPageProps) {
|
||||
try {
|
||||
const res = await internal.get<AxiosResponse<ListResourcesResponse>>(
|
||||
`/org/${params.orgId}/resources`,
|
||||
await authCookieHeader()
|
||||
await authCookieHeader(),
|
||||
);
|
||||
resources = res.data.data.resources;
|
||||
} catch (e) {
|
||||
@@ -31,8 +31,8 @@ export default async function ResourcesPage(props: ResourcesPageProps) {
|
||||
const getOrg = cache(async () =>
|
||||
internal.get<AxiosResponse<GetOrgResponse>>(
|
||||
`/org/${params.orgId}`,
|
||||
await authCookieHeader()
|
||||
)
|
||||
await authCookieHeader(),
|
||||
),
|
||||
);
|
||||
const res = await getOrg();
|
||||
org = res.data.data;
|
||||
@@ -49,8 +49,13 @@ export default async function ResourcesPage(props: ResourcesPageProps) {
|
||||
id: resource.resourceId,
|
||||
name: resource.name,
|
||||
orgId: params.orgId,
|
||||
domain: resource.subdomain || "",
|
||||
domain: `${resource.ssl ? "https://" : "http://"}${resource.fullDomain}`,
|
||||
site: resource.siteName || "None",
|
||||
siteId: resource.siteId || "Unknown",
|
||||
hasAuth:
|
||||
resource.sso ||
|
||||
resource.pincodeId !== null ||
|
||||
resource.pincodeId !== null,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ import { Button } from "@app/components/ui/button";
|
||||
import { useState } from "react";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { DataTablePagination } from "../../../../../components/DataTablePagination";
|
||||
import { Plus } from "lucide-react";
|
||||
import { Plus, Search } from "lucide-react";
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
@@ -62,19 +62,23 @@ export function SitesDataTable<TData, TValue>({
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between pb-4">
|
||||
<Input
|
||||
placeholder="Search sites"
|
||||
value={
|
||||
(table.getColumn("name")?.getFilterValue() as string) ??
|
||||
""
|
||||
}
|
||||
onChange={(event) =>
|
||||
table
|
||||
.getColumn("name")
|
||||
?.setFilterValue(event.target.value)
|
||||
}
|
||||
className="max-w-sm mr-2"
|
||||
/>
|
||||
<div className="flex items-center max-w-sm mr-2 w-full relative">
|
||||
<Input
|
||||
placeholder="Search sites"
|
||||
value={
|
||||
(table
|
||||
.getColumn("name")
|
||||
?.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>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (addSite) {
|
||||
@@ -85,7 +89,7 @@ export function SitesDataTable<TData, TValue>({
|
||||
<Plus className="mr-2 h-4 w-4" /> Add Site
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<div className="border rounded-md">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
@@ -98,7 +102,7 @@ export function SitesDataTable<TData, TValue>({
|
||||
: flexRender(
|
||||
header.column.columnDef
|
||||
.header,
|
||||
header.getContext()
|
||||
header.getContext(),
|
||||
)}
|
||||
</TableHead>
|
||||
);
|
||||
@@ -119,7 +123,7 @@ export function SitesDataTable<TData, TValue>({
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
cell.getContext(),
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
|
||||
@@ -24,9 +24,10 @@ export type SiteRow = {
|
||||
id: number;
|
||||
nice: string;
|
||||
name: string;
|
||||
mbIn: number;
|
||||
mbOut: number;
|
||||
mbIn: string;
|
||||
mbOut: string;
|
||||
orgId: string;
|
||||
type: "newt" | "wireguard";
|
||||
};
|
||||
|
||||
type SitesTableProps = {
|
||||
@@ -99,11 +100,70 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
|
||||
},
|
||||
{
|
||||
accessorKey: "mbIn",
|
||||
header: "MB In",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Data In
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "mbOut",
|
||||
header: "MB Out",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Data Out
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "type",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Connection Type
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const originalRow = row.original;
|
||||
|
||||
if (originalRow.type === "newt") {
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<span>Newt</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (originalRow.type === "wireguard") {
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<span>WireGuard</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
@@ -135,24 +195,21 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
|
||||
setSelectedSite(siteRow);
|
||||
setIsDeleteModalOpen(true);
|
||||
}}
|
||||
className="text-red-600 hover:text-red-800"
|
||||
className="text-red-500"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button
|
||||
variant={"gray"}
|
||||
className="ml-2"
|
||||
onClick={() =>
|
||||
router.push(
|
||||
`/${siteRow.orgId}/settings/sites/${siteRow.nice}`
|
||||
)
|
||||
}
|
||||
<Link
|
||||
href={`/${siteRow.orgId}/settings/sites/${siteRow.nice}`}
|
||||
>
|
||||
Edit <ArrowRight className="ml-2 w-4 h-4" />
|
||||
</Button>
|
||||
<Button variant={"gray"} className="ml-2">
|
||||
Edit
|
||||
<ArrowRight className="ml-2 w-4 h-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -15,21 +15,32 @@ export default async function SitesPage(props: SitesPageProps) {
|
||||
try {
|
||||
const res = await internal.get<AxiosResponse<ListSitesResponse>>(
|
||||
`/org/${params.orgId}/sites`,
|
||||
await authCookieHeader()
|
||||
await authCookieHeader(),
|
||||
);
|
||||
sites = res.data.data.sites;
|
||||
} catch (e) {
|
||||
console.error("Error fetching sites", e);
|
||||
}
|
||||
|
||||
function formatSize(mb: number): string {
|
||||
if (mb >= 1024 * 1024) {
|
||||
return `${(mb / (1024 * 1024)).toFixed(2)} TB`;
|
||||
} else if (mb >= 1024) {
|
||||
return `${(mb / 1024).toFixed(2)} GB`;
|
||||
} else {
|
||||
return `${mb.toFixed(2)} MB`;
|
||||
}
|
||||
}
|
||||
|
||||
const siteRows: SiteRow[] = sites.map((site) => {
|
||||
return {
|
||||
name: site.name,
|
||||
id: site.siteId,
|
||||
nice: site.niceId.toString(),
|
||||
mbIn: site.megabytesIn || 0,
|
||||
mbOut: site.megabytesOut || 0,
|
||||
mbIn: formatSize(site.megabytesIn || 0),
|
||||
mbOut: formatSize(site.megabytesOut || 0),
|
||||
orgId: params.orgId,
|
||||
type: site.type as any,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -37,7 +48,7 @@ export default async function SitesPage(props: SitesPageProps) {
|
||||
<>
|
||||
<SettingsSectionTitle
|
||||
title="Manage Sites"
|
||||
description="Manage your existing sites here or create a new one."
|
||||
description="Allow connectivity to your network through secure tunnels"
|
||||
/>
|
||||
|
||||
<SitesTable sites={siteRows} orgId={params.orgId} />
|
||||
|
||||
@@ -30,7 +30,15 @@ export default function DashboardLoginForm({
|
||||
<CardContent>
|
||||
<LoginForm
|
||||
redirect={redirect}
|
||||
onLogin={() => router.push("/")}
|
||||
onLogin={() => {
|
||||
if (redirect && redirect.includes("http")) {
|
||||
window.location.href = redirect;
|
||||
} else if (redirect) {
|
||||
router.push(redirect);
|
||||
} else {
|
||||
router.push("/");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -5,8 +8,9 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@app/components/ui/card";
|
||||
import Link from "next/link";
|
||||
|
||||
export default async function ResourceAccessDenied() {
|
||||
export default function ResourceAccessDenied() {
|
||||
return (
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
@@ -17,6 +21,11 @@ export default async function ResourceAccessDenied() {
|
||||
<CardContent>
|
||||
You're not alowed to access this resource. If this is a mistake,
|
||||
please contact the administrator.
|
||||
<div className="text-center mt-4">
|
||||
<Button>
|
||||
<Link href="/">Go Home</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { LockIcon, UserIcon, Binary, Key, User } from "lucide-react";
|
||||
import { LockIcon, Binary, Key, User } from "lucide-react";
|
||||
import {
|
||||
InputOTP,
|
||||
InputOTPGroup,
|
||||
@@ -34,10 +34,10 @@ import { useRouter } from "next/navigation";
|
||||
import { Alert, AlertDescription } from "@app/components/ui/alert";
|
||||
import { formatAxiosError } from "@app/lib/utils";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { LoginResponse } from "@server/routers/auth";
|
||||
import ResourceAccessDenied from "./ResourceAccessDenied";
|
||||
import LoginForm from "@app/components/LoginForm";
|
||||
import { AuthWithPasswordResponse } from "@server/routers/resource";
|
||||
import { redirect } from "next/dist/server/api-utils";
|
||||
import ResourceAccessDenied from "./ResourceAccessDenied";
|
||||
|
||||
const pinSchema = z.object({
|
||||
pin: z
|
||||
@@ -63,7 +63,6 @@ type ResourceAuthPortalProps = {
|
||||
id: number;
|
||||
};
|
||||
redirect: string;
|
||||
queryParamName: string;
|
||||
};
|
||||
|
||||
export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||
@@ -114,13 +113,6 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||
},
|
||||
});
|
||||
|
||||
function constructRedirect(redirect: string, token: string): string {
|
||||
const redirectUrl = new URL(redirect);
|
||||
redirectUrl.searchParams.delete(props.queryParamName);
|
||||
redirectUrl.searchParams.append(props.queryParamName, token);
|
||||
return redirectUrl.toString();
|
||||
}
|
||||
|
||||
const onPinSubmit = (values: z.infer<typeof pinSchema>) => {
|
||||
setLoadingLogin(true);
|
||||
api.post<AxiosResponse<AuthWithPasswordResponse>>(
|
||||
@@ -130,10 +122,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||
.then((res) => {
|
||||
const session = res.data.data.session;
|
||||
if (session) {
|
||||
window.location.href = constructRedirect(
|
||||
props.redirect,
|
||||
session,
|
||||
);
|
||||
window.location.href = props.redirect;
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
@@ -156,10 +145,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||
.then((res) => {
|
||||
const session = res.data.data.session;
|
||||
if (session) {
|
||||
window.location.href = constructRedirect(
|
||||
props.redirect,
|
||||
session,
|
||||
);
|
||||
window.location.href = props.redirect;
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
@@ -172,13 +158,15 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||
};
|
||||
|
||||
async function handleSSOAuth() {
|
||||
console.log("SSO authentication");
|
||||
|
||||
await api.get(`/resource/${props.resource.id}`).catch((e) => {
|
||||
let isAllowed = false;
|
||||
try {
|
||||
await api.get(`/resource/${props.resource.id}`);
|
||||
isAllowed = true;
|
||||
} catch (e) {
|
||||
setAccessDenied(true);
|
||||
});
|
||||
}
|
||||
|
||||
if (!accessDenied) {
|
||||
if (isAllowed) {
|
||||
window.location.href = props.redirect;
|
||||
}
|
||||
}
|
||||
@@ -187,6 +175,11 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||
<div>
|
||||
{!accessDenied ? (
|
||||
<div>
|
||||
<div className="text-center mb-2">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Powered by Fossorial
|
||||
</span>
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Authentication Required</CardTitle>
|
||||
@@ -378,6 +371,11 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||
className={`${numMethods <= 1 ? "mt-0" : ""}`}
|
||||
>
|
||||
<LoginForm
|
||||
redirect={
|
||||
typeof window !== "undefined"
|
||||
? window.location.href
|
||||
: ""
|
||||
}
|
||||
onLogin={async () =>
|
||||
await handleSSOAuth()
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -5,6 +6,7 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@app/components/ui/card";
|
||||
import Link from "next/link";
|
||||
|
||||
export default async function ResourceNotFound() {
|
||||
return (
|
||||
@@ -15,7 +17,12 @@ export default async function ResourceNotFound() {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
The resource you're trying to access does not exist
|
||||
The resource you're trying to access does not exist.
|
||||
<div className="text-center mt-4">
|
||||
<Button>
|
||||
<Link href="/">Go Home</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
GetResourceResponse,
|
||||
} from "@server/routers/resource";
|
||||
import ResourceAuthPortal from "./components/ResourceAuthPortal";
|
||||
import { internal } from "@app/api";
|
||||
import { internal, priv } from "@app/api";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { authCookieHeader } from "@app/api/cookies";
|
||||
import { cache } from "react";
|
||||
@@ -11,10 +11,12 @@ import { verifySession } from "@app/lib/auth/verifySession";
|
||||
import { redirect } from "next/navigation";
|
||||
import ResourceNotFound from "./components/ResourceNotFound";
|
||||
import ResourceAccessDenied from "./components/ResourceAccessDenied";
|
||||
import { cookies } from "next/headers";
|
||||
import { CheckResourceSessionResponse } from "@server/routers/auth";
|
||||
|
||||
export default async function ResourceAuthPage(props: {
|
||||
params: Promise<{ resourceId: number }>;
|
||||
searchParams: Promise<{ redirect: string }>;
|
||||
searchParams: Promise<{ redirect: string | undefined }>;
|
||||
}) {
|
||||
const params = await props.params;
|
||||
const searchParams = await props.searchParams;
|
||||
@@ -31,7 +33,7 @@ export default async function ResourceAuthPage(props: {
|
||||
} catch (e) {}
|
||||
|
||||
const getUser = cache(verifySession);
|
||||
const user = await getUser();
|
||||
const user = await getUser({ skipCheckVerifyEmail: true });
|
||||
|
||||
if (!authInfo) {
|
||||
return (
|
||||
@@ -46,6 +48,40 @@ export default async function ResourceAuthPage(props: {
|
||||
|
||||
const redirectUrl = searchParams.redirect || authInfo.url;
|
||||
|
||||
if (
|
||||
user &&
|
||||
!user.emailVerified &&
|
||||
process.env.FLAGS_EMAIL_VERIFICATION_REQUIRED === "true"
|
||||
) {
|
||||
redirect(
|
||||
`/auth/verify-email?redirect=/auth/resource/${authInfo.resourceId}`,
|
||||
);
|
||||
}
|
||||
|
||||
const allCookies = await cookies();
|
||||
const cookieName =
|
||||
process.env.RESOURCE_SESSION_COOKIE_NAME + `_${params.resourceId}`;
|
||||
const sessionId = allCookies.get(cookieName)?.value ?? null;
|
||||
|
||||
if (sessionId) {
|
||||
let doRedirect = false;
|
||||
try {
|
||||
const res = await priv.get<
|
||||
AxiosResponse<CheckResourceSessionResponse>
|
||||
>(`/resource-session/${params.resourceId}/${sessionId}`);
|
||||
|
||||
console.log("resource session already exists and is valid");
|
||||
|
||||
if (res && res.data.data.valid) {
|
||||
doRedirect = true;
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
if (doRedirect) {
|
||||
redirect(redirectUrl);
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasAuth) {
|
||||
// no authentication so always go straight to the resource
|
||||
redirect(redirectUrl);
|
||||
@@ -63,7 +99,6 @@ export default async function ResourceAuthPage(props: {
|
||||
console.log(res.data);
|
||||
doRedirect = true;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
userIsUnauthorized = true;
|
||||
}
|
||||
|
||||
@@ -72,33 +107,28 @@ export default async function ResourceAuthPage(props: {
|
||||
}
|
||||
}
|
||||
|
||||
if (userIsUnauthorized && isSSOOnly) {
|
||||
return (
|
||||
<div className="w-full max-w-md">
|
||||
<ResourceAccessDenied />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-full max-w-md">
|
||||
<ResourceAuthPortal
|
||||
methods={{
|
||||
password: authInfo.password,
|
||||
pincode: authInfo.pincode,
|
||||
sso: authInfo.sso && !userIsUnauthorized,
|
||||
}}
|
||||
resource={{
|
||||
name: authInfo.resourceName,
|
||||
id: authInfo.resourceId,
|
||||
}}
|
||||
redirect={redirectUrl}
|
||||
queryParamName={
|
||||
process.env.RESOURCE_SESSION_QUERY_PARAM_NAME!
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{userIsUnauthorized && isSSOOnly ? (
|
||||
<div className="w-full max-w-md">
|
||||
<ResourceAccessDenied />
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full max-w-md">
|
||||
<ResourceAuthPortal
|
||||
methods={{
|
||||
password: authInfo.password,
|
||||
pincode: authInfo.pincode,
|
||||
sso: authInfo.sso && !userIsUnauthorized,
|
||||
}}
|
||||
resource={{
|
||||
name: authInfo.resourceName,
|
||||
id: authInfo.resourceId,
|
||||
}}
|
||||
redirect={redirectUrl}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -79,6 +79,7 @@ export default function VerifyEmailForm({
|
||||
.catch((e) => {
|
||||
setError(formatAxiosError(e, "An error occurred"));
|
||||
console.error("Failed to verify email:", e);
|
||||
setIsSubmitting(false);
|
||||
});
|
||||
|
||||
if (res && res.data?.data?.valid) {
|
||||
@@ -125,7 +126,7 @@ export default function VerifyEmailForm({
|
||||
<div>
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>Verify Your Email</CardTitle>
|
||||
<CardTitle>Verify Email</CardTitle>
|
||||
<CardDescription>
|
||||
Enter the verification code sent to your email address.
|
||||
</CardDescription>
|
||||
@@ -234,7 +235,7 @@ export default function VerifyEmailForm({
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="text-center text-muted-foreground mt-4">
|
||||
<div className="text-center text-muted-foreground mt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
|
||||
@@ -8,13 +8,13 @@ export const dynamic = "force-dynamic";
|
||||
export default async function Page(props: {
|
||||
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||
}) {
|
||||
if (process.env.PUBLIC_FLAGS_EMAIL_VERIFICATION_REQUIRED !== "true") {
|
||||
if (process.env.FLAGS_EMAIL_VERIFICATION_REQUIRED !== "true") {
|
||||
redirect("/");
|
||||
}
|
||||
|
||||
const searchParams = await props.searchParams;
|
||||
const getUser = cache(verifySession);
|
||||
const user = await getUser();
|
||||
const user = await getUser({ skipCheckVerifyEmail: true });
|
||||
|
||||
if (!user) {
|
||||
redirect("/");
|
||||
|
||||
@@ -6,11 +6,11 @@
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 20 14.3% 4.1%;
|
||||
--foreground: 20 5.0% 10.0%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 20 14.3% 4.1%;
|
||||
--card-foreground: 20 5.0% 10.0%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 20 14.3% 4.1%;
|
||||
--popover-foreground: 20 5.0% 10.0%;
|
||||
--primary: 24.6 95% 53.1%;
|
||||
--primary-foreground: 60 9.1% 97.8%;
|
||||
--secondary: 60 4.8% 95.9%;
|
||||
@@ -33,24 +33,24 @@
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 20 14.3% 4.1%;
|
||||
--background: 20 5.0% 10.0%;
|
||||
--foreground: 60 9.1% 97.8%;
|
||||
--card: 20 14.3% 4.1%;
|
||||
--card: 20 5.0% 10.0%;
|
||||
--card-foreground: 60 9.1% 97.8%;
|
||||
--popover: 20 14.3% 4.1%;
|
||||
--popover: 20 5.0% 10.0%;
|
||||
--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% 15.1%;
|
||||
--secondary: 12 6.5% 25.0%;
|
||||
--secondary-foreground: 60 9.1% 97.8%;
|
||||
--muted: 12 6.5% 15.1%;
|
||||
--muted: 12 6.5% 25.0%;
|
||||
--muted-foreground: 24 5.4% 63.9%;
|
||||
--accent: 12 6.5% 15.1%;
|
||||
--accent: 12 6.5% 25.0%;
|
||||
--accent-foreground: 60 9.1% 97.8%;
|
||||
--destructive: 0 72.2% 50.6%;
|
||||
--destructive-foreground: 60 9.1% 97.8%;
|
||||
--border: 12 6.5% 15.1%;
|
||||
--input: 12 6.5% 15.1%;
|
||||
--border: 12 6.5% 25.0%;
|
||||
--input: 12 6.5% 25.0%;
|
||||
--ring: 20.5 90.2% 48.2%;
|
||||
--chart-1: 220 70% 50%;
|
||||
--chart-2: 160 60% 45%;
|
||||
|
||||
@@ -21,7 +21,7 @@ export default async function InvitePage(props: {
|
||||
const user = await verifySession();
|
||||
|
||||
if (!user) {
|
||||
redirect(`/auth/login?redirect=/invite?token=${params.token}`);
|
||||
redirect(`/?redirect=/invite?token=${params.token}`);
|
||||
}
|
||||
|
||||
const parts = tokenParam.split("-");
|
||||
|
||||
@@ -12,21 +12,36 @@ import { cache } from "react";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function Page(props: {
|
||||
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||
searchParams: Promise<{ redirect: string | undefined }>;
|
||||
}) {
|
||||
const params = await props.searchParams; // this is needed to prevent static optimization
|
||||
const getUser = cache(verifySession);
|
||||
const user = await getUser();
|
||||
const user = await getUser({ skipCheckVerifyEmail: true });
|
||||
|
||||
if (!user) {
|
||||
redirect("/auth/login");
|
||||
if (params.redirect) {
|
||||
redirect(`/auth/login?redirect=${params.redirect}`);
|
||||
} else {
|
||||
redirect(`/auth/login`);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
!user.emailVerified &&
|
||||
process.env.FLAGS_EMAIL_VERIFICATION_REQUIRED === "true"
|
||||
) {
|
||||
if (params.redirect) {
|
||||
redirect(`/auth/verify-email?redirect=${params.redirect}`);
|
||||
} else {
|
||||
redirect(`/auth/verify-email`);
|
||||
}
|
||||
}
|
||||
|
||||
let orgs: ListOrgsResponse["orgs"] = [];
|
||||
try {
|
||||
const res = await internal.get<AxiosResponse<ListOrgsResponse>>(
|
||||
`/orgs`,
|
||||
await authCookieHeader()
|
||||
await authCookieHeader(),
|
||||
);
|
||||
|
||||
if (res && res.data.data.orgs) {
|
||||
|
||||
@@ -1,14 +1,26 @@
|
||||
import { verifySession } from "@app/lib/auth/verifySession";
|
||||
import { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { cache } from "react";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: `Setup - Pangolin`,
|
||||
description: "",
|
||||
};
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function SetupLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const getUser = cache(verifySession);
|
||||
const user = await getUser();
|
||||
|
||||
if (!user) {
|
||||
redirect("/?redirect=/setup");
|
||||
}
|
||||
|
||||
return <div className="mt-32">{children}</div>;
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ export default function StepperForm() {
|
||||
|
||||
const debouncedCheckOrgIdAvailability = useCallback(
|
||||
debounce(checkOrgIdAvailability, 300),
|
||||
[checkOrgIdAvailability]
|
||||
[checkOrgIdAvailability],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -278,7 +278,7 @@ export default function StepperForm() {
|
||||
|
||||
function debounce<T extends (...args: any[]) => any>(
|
||||
func: T,
|
||||
wait: number
|
||||
wait: number,
|
||||
): (...args: Parameters<T>) => void {
|
||||
let timeout: NodeJS.Timeout | null = null;
|
||||
|
||||
|
||||
@@ -75,8 +75,6 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) {
|
||||
if (res && res.status === 200) {
|
||||
setError(null);
|
||||
|
||||
console.log(res);
|
||||
|
||||
if (res.data?.data?.emailVerificationRequired) {
|
||||
if (redirect) {
|
||||
router.push(`/auth/verify-email?redirect=${redirect}`);
|
||||
@@ -86,14 +84,8 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (redirect && redirect.includes("http")) {
|
||||
window.location.href = redirect;
|
||||
} else if (redirect) {
|
||||
router.push(redirect);
|
||||
} else {
|
||||
if (onLogin) {
|
||||
await onLogin();
|
||||
}
|
||||
if (onLogin) {
|
||||
await onLogin();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,15 +3,33 @@ import { authCookieHeader } from "@app/api/cookies";
|
||||
import { GetUserResponse } from "@server/routers/user";
|
||||
import { AxiosResponse } from "axios";
|
||||
|
||||
export async function verifySession(): Promise<GetUserResponse | null> {
|
||||
export async function verifySession({
|
||||
skipCheckVerifyEmail,
|
||||
}: {
|
||||
skipCheckVerifyEmail?: boolean;
|
||||
} = {}): Promise<GetUserResponse | null> {
|
||||
try {
|
||||
const res = await internal.get<AxiosResponse<GetUserResponse>>(
|
||||
"/user",
|
||||
await authCookieHeader()
|
||||
await authCookieHeader(),
|
||||
);
|
||||
|
||||
return res.data.data;
|
||||
} catch {
|
||||
const user = res.data.data;
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
!skipCheckVerifyEmail &&
|
||||
!user.emailVerified &&
|
||||
process.env.FLAGS_EMAIL_VERIFICATION_REQUIRED == "true"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return user;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user