Merge branch 'main' of https://github.com/fosrl/pangolin
This commit is contained in:
commit
0a86f193ac
75 changed files with 1983 additions and 2559 deletions
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@fossorial/pangolin",
|
"name": "@fossorial/pangolin",
|
||||||
"version": "0.1.0",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
@ -60,6 +60,7 @@
|
||||||
"node-fetch": "3.3.2",
|
"node-fetch": "3.3.2",
|
||||||
"nodemailer": "6.9.15",
|
"nodemailer": "6.9.15",
|
||||||
"oslo": "1.2.1",
|
"oslo": "1.2.1",
|
||||||
|
"qrcode.react": "4.2.0",
|
||||||
"react": "19.0.0-rc.1",
|
"react": "19.0.0-rc.1",
|
||||||
"react-dom": "19.0.0-rc.1",
|
"react-dom": "19.0.0-rc.1",
|
||||||
"react-hook-form": "7.53.0",
|
"react-hook-form": "7.53.0",
|
||||||
|
@ -74,7 +75,6 @@
|
||||||
"zod-validation-error": "3.4.0"
|
"zod-validation-error": "3.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"react-email": "3.0.2",
|
|
||||||
"@dotenvx/dotenvx": "1.14.2",
|
"@dotenvx/dotenvx": "1.14.2",
|
||||||
"@esbuild-plugins/tsconfig-paths": "0.1.2",
|
"@esbuild-plugins/tsconfig-paths": "0.1.2",
|
||||||
"@types/better-sqlite3": "7.6.11",
|
"@types/better-sqlite3": "7.6.11",
|
||||||
|
@ -92,6 +92,7 @@
|
||||||
"esbuild": "0.20.1",
|
"esbuild": "0.20.1",
|
||||||
"esbuild-node-externals": "1.13.0",
|
"esbuild-node-externals": "1.13.0",
|
||||||
"postcss": "^8",
|
"postcss": "^8",
|
||||||
|
"react-email": "3.0.2",
|
||||||
"tailwindcss": "^3.4.1",
|
"tailwindcss": "^3.4.1",
|
||||||
"tsc-alias": "1.8.10",
|
"tsc-alias": "1.8.10",
|
||||||
"tsx": "4.19.1",
|
"tsx": "4.19.1",
|
||||||
|
|
|
@ -4,19 +4,22 @@ import { twoFactorBackupCodes } from "@server/db/schema";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { decodeHex } from "oslo/encoding";
|
import { decodeHex } from "oslo/encoding";
|
||||||
import { TOTPController } from "oslo/otp";
|
import { TOTPController } from "oslo/otp";
|
||||||
|
import { verifyPassword } from "./password";
|
||||||
|
|
||||||
export async function verifyTotpCode(
|
export async function verifyTotpCode(
|
||||||
code: string,
|
code: string,
|
||||||
secret: string,
|
secret: string,
|
||||||
userId: string,
|
userId: string
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
if (code.length !== 6) {
|
// if code is digits only, it's totp
|
||||||
|
const isTotp = /^\d+$/.test(code);
|
||||||
|
if (!isTotp) {
|
||||||
const validBackupCode = await verifyBackUpCode(code, userId);
|
const validBackupCode = await verifyBackUpCode(code, userId);
|
||||||
return validBackupCode;
|
return validBackupCode;
|
||||||
} else {
|
} else {
|
||||||
const validOTP = await new TOTPController().verify(
|
const validOTP = await new TOTPController().verify(
|
||||||
code,
|
code,
|
||||||
decodeHex(secret),
|
decodeHex(secret)
|
||||||
);
|
);
|
||||||
|
|
||||||
return validOTP;
|
return validOTP;
|
||||||
|
@ -25,7 +28,7 @@ export async function verifyTotpCode(
|
||||||
|
|
||||||
export async function verifyBackUpCode(
|
export async function verifyBackUpCode(
|
||||||
code: string,
|
code: string,
|
||||||
userId: string,
|
userId: string
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const allHashed = await db
|
const allHashed = await db
|
||||||
.select()
|
.select()
|
||||||
|
@ -38,12 +41,7 @@ export async function verifyBackUpCode(
|
||||||
|
|
||||||
let validId;
|
let validId;
|
||||||
for (const hashedCode of allHashed) {
|
for (const hashedCode of allHashed) {
|
||||||
const validCode = await verify(hashedCode.codeHash, code, {
|
const validCode = await verifyPassword(code, hashedCode.codeHash);
|
||||||
memoryCost: 19456,
|
|
||||||
timeCost: 2,
|
|
||||||
outputLen: 32,
|
|
||||||
parallelism: 1,
|
|
||||||
});
|
|
||||||
if (validCode) {
|
if (validCode) {
|
||||||
validId = hashedCode.codeId;
|
validId = hashedCode.codeId;
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,6 +8,7 @@ import { sendEmail } from "@server/emails";
|
||||||
import ResourceOTPCode from "@server/emails/templates/ResourceOTPCode";
|
import ResourceOTPCode from "@server/emails/templates/ResourceOTPCode";
|
||||||
import config from "@server/config";
|
import config from "@server/config";
|
||||||
import { hash, verify } from "@node-rs/argon2";
|
import { hash, verify } from "@node-rs/argon2";
|
||||||
|
import { hashPassword } from "./password";
|
||||||
|
|
||||||
export async function sendResourceOtpEmail(
|
export async function sendResourceOtpEmail(
|
||||||
email: string,
|
email: string,
|
||||||
|
@ -47,12 +48,7 @@ export async function generateResourceOtpCode(
|
||||||
|
|
||||||
const otp = generateRandomString(8, alphabet("0-9", "A-Z", "a-z"));
|
const otp = generateRandomString(8, alphabet("0-9", "A-Z", "a-z"));
|
||||||
|
|
||||||
const otpHash = await hash(otp, {
|
const otpHash = await hashPassword(otp);
|
||||||
memoryCost: 19456,
|
|
||||||
timeCost: 2,
|
|
||||||
outputLen: 32,
|
|
||||||
parallelism: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
await db.insert(resourceOtp).values({
|
await db.insert(resourceOtp).values({
|
||||||
resourceId,
|
resourceId,
|
||||||
|
@ -84,12 +80,7 @@ export async function isValidOtp(
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const validCode = await verify(record[0].otpHash, otp, {
|
const validCode = await verifyPassword(otp, record[0].otpHash);
|
||||||
memoryCost: 19456,
|
|
||||||
timeCost: 2,
|
|
||||||
outputLen: 32,
|
|
||||||
parallelism: 1
|
|
||||||
});
|
|
||||||
if (!validCode) {
|
if (!validCode) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
|
@ -132,6 +132,17 @@ if (!parsedConfig.success) {
|
||||||
throw new Error(`Invalid configuration file: ${errors}`);
|
throw new Error(`Invalid configuration file: ${errors}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const packageJsonPath = path.join(__DIRNAME, "..", "package.json");
|
||||||
|
let packageJson: any;
|
||||||
|
if (fs.existsSync && fs.existsSync(packageJsonPath)) {
|
||||||
|
const packageJsonContent = fs.readFileSync(packageJsonPath, "utf8");
|
||||||
|
packageJson = JSON.parse(packageJsonContent);
|
||||||
|
|
||||||
|
if (packageJson.version) {
|
||||||
|
process.env.APP_VERSION = packageJson.version;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
process.env.NEXT_PORT = parsedConfig.data.server.next_port.toString();
|
process.env.NEXT_PORT = parsedConfig.data.server.next_port.toString();
|
||||||
process.env.SERVER_EXTERNAL_PORT =
|
process.env.SERVER_EXTERNAL_PORT =
|
||||||
parsedConfig.data.server.external_port.toString();
|
parsedConfig.data.server.external_port.toString();
|
||||||
|
|
|
@ -150,6 +150,7 @@ export const emailVerificationCodes = sqliteTable("emailVerificationCodes", {
|
||||||
|
|
||||||
export const passwordResetTokens = sqliteTable("passwordResetTokens", {
|
export const passwordResetTokens = sqliteTable("passwordResetTokens", {
|
||||||
tokenId: integer("id").primaryKey({ autoIncrement: true }),
|
tokenId: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
|
email: text("email").notNull(),
|
||||||
userId: text("userId")
|
userId: text("userId")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => users.userId, { onDelete: "cascade" }),
|
.references(() => users.userId, { onDelete: "cascade" }),
|
||||||
|
|
70
server/emails/templates/NotifyResetPassword.tsx
Normal file
70
server/emails/templates/NotifyResetPassword.tsx
Normal file
|
@ -0,0 +1,70 @@
|
||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Container,
|
||||||
|
Head,
|
||||||
|
Heading,
|
||||||
|
Html,
|
||||||
|
Preview,
|
||||||
|
Section,
|
||||||
|
Text,
|
||||||
|
Tailwind
|
||||||
|
} from "@react-email/components";
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ConfirmPasswordReset = ({ email }: Props) => {
|
||||||
|
const previewText = `Your password has been reset`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Html>
|
||||||
|
<Head />
|
||||||
|
<Preview>{previewText}</Preview>
|
||||||
|
<Tailwind
|
||||||
|
config={{
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
primary: "#16A34A"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Body className="font-sans">
|
||||||
|
<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">
|
||||||
|
Your password has been successfully reset
|
||||||
|
</Heading>
|
||||||
|
<Text className="text-base text-gray-700 mt-4">
|
||||||
|
Hi {email || "there"},
|
||||||
|
</Text>
|
||||||
|
<Text className="text-base text-gray-700 mt-2">
|
||||||
|
This email confirms that your password has just been
|
||||||
|
reset. If you made this change, no further action is
|
||||||
|
required.
|
||||||
|
</Text>
|
||||||
|
<Section className="text-center my-6">
|
||||||
|
<Text className="text-base text-gray-700">
|
||||||
|
If you did not request this change, please
|
||||||
|
contact our support team immediately.
|
||||||
|
</Text>
|
||||||
|
</Section>
|
||||||
|
<Text className="text-base text-gray-700 mt-2">
|
||||||
|
Thank you for keeping your account secure.
|
||||||
|
</Text>
|
||||||
|
<Text className="text-sm text-gray-500 mt-6">
|
||||||
|
Best regards,
|
||||||
|
<br />
|
||||||
|
Fossorial
|
||||||
|
</Text>
|
||||||
|
</Container>
|
||||||
|
</Body>
|
||||||
|
</Tailwind>
|
||||||
|
</Html>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ConfirmPasswordReset;
|
75
server/emails/templates/ResetPasswordCode.tsx
Normal file
75
server/emails/templates/ResetPasswordCode.tsx
Normal file
|
@ -0,0 +1,75 @@
|
||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Container,
|
||||||
|
Head,
|
||||||
|
Heading,
|
||||||
|
Html,
|
||||||
|
Preview,
|
||||||
|
Section,
|
||||||
|
Text,
|
||||||
|
Tailwind
|
||||||
|
} from "@react-email/components";
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
email: string;
|
||||||
|
code: string;
|
||||||
|
link: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ResetPasswordCode = ({ email, code, link }: Props) => {
|
||||||
|
const previewText = `Reset your password, ${email}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Html>
|
||||||
|
<Head />
|
||||||
|
<Preview>{previewText}</Preview>
|
||||||
|
<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 rounded-lg">
|
||||||
|
<Heading className="text-2xl font-semibold text-gray-800 text-center">
|
||||||
|
You've requested to reset your password
|
||||||
|
</Heading>
|
||||||
|
<Text className="text-base text-gray-700 mt-4">
|
||||||
|
Hi {email || "there"},
|
||||||
|
</Text>
|
||||||
|
<Text className="text-base text-gray-700 mt-2">
|
||||||
|
You’ve requested to reset your password. Please{" "}
|
||||||
|
<a href={link} className="text-primary">
|
||||||
|
click here
|
||||||
|
</a>{" "}
|
||||||
|
and follow the instructions to reset your password,
|
||||||
|
or manually enter the following code:
|
||||||
|
</Text>
|
||||||
|
<Section className="text-center my-6">
|
||||||
|
<Text className="inline-block bg-primary text-xl font-bold text-white py-2 px-4 border border-gray-300 rounded-xl">
|
||||||
|
{code}
|
||||||
|
</Text>
|
||||||
|
</Section>
|
||||||
|
<Text className="text-base text-gray-700 mt-2">
|
||||||
|
If you didn’t request this, you can safely ignore
|
||||||
|
this email.
|
||||||
|
</Text>
|
||||||
|
<Text className="text-sm text-gray-500 mt-6">
|
||||||
|
Best regards,
|
||||||
|
<br />
|
||||||
|
Fossorial
|
||||||
|
</Text>
|
||||||
|
</Container>
|
||||||
|
</Body>
|
||||||
|
</Tailwind>
|
||||||
|
</Html>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ResetPasswordCode;
|
|
@ -61,6 +61,12 @@ export const ResourceOTPCode = ({
|
||||||
{otp}
|
{otp}
|
||||||
</Text>
|
</Text>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
|
<Text className="text-sm text-gray-500 mt-6">
|
||||||
|
Best regards,
|
||||||
|
<br />
|
||||||
|
Fossorial
|
||||||
|
</Text>
|
||||||
</Container>
|
</Container>
|
||||||
</Body>
|
</Body>
|
||||||
</Tailwind>
|
</Tailwind>
|
||||||
|
|
|
@ -8,7 +8,7 @@ import {
|
||||||
Section,
|
Section,
|
||||||
Text,
|
Text,
|
||||||
Tailwind,
|
Tailwind,
|
||||||
Button,
|
Button
|
||||||
} from "@react-email/components";
|
} from "@react-email/components";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
|
||||||
|
@ -25,7 +25,7 @@ export const SendInviteLink = ({
|
||||||
inviteLink,
|
inviteLink,
|
||||||
orgName,
|
orgName,
|
||||||
inviterName,
|
inviterName,
|
||||||
expiresInDays,
|
expiresInDays
|
||||||
}: SendInviteLinkProps) => {
|
}: SendInviteLinkProps) => {
|
||||||
const previewText = `${inviterName} invited to join ${orgName}`;
|
const previewText = `${inviterName} invited to join ${orgName}`;
|
||||||
|
|
||||||
|
@ -33,7 +33,8 @@ export const SendInviteLink = ({
|
||||||
<Html>
|
<Html>
|
||||||
<Head />
|
<Head />
|
||||||
<Preview>{previewText}</Preview>
|
<Preview>{previewText}</Preview>
|
||||||
<Tailwind config={{
|
<Tailwind
|
||||||
|
config={{
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
colors: {
|
colors: {
|
||||||
|
@ -41,7 +42,8 @@ export const SendInviteLink = ({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}}>
|
}}
|
||||||
|
>
|
||||||
<Body className="font-sans">
|
<Body className="font-sans">
|
||||||
<Container className="bg-white border border-solid border-gray-200 p-6 max-w-lg mx-auto my-8 rounded-lg">
|
<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">
|
<Heading className="text-2xl font-semibold text-gray-800 text-center">
|
||||||
|
@ -71,6 +73,12 @@ export const SendInviteLink = ({
|
||||||
Accept invitation to {orgName}
|
Accept invitation to {orgName}
|
||||||
</Button>
|
</Button>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
|
<Text className="text-sm text-gray-500 mt-6">
|
||||||
|
Best regards,
|
||||||
|
<br />
|
||||||
|
Fossorial
|
||||||
|
</Text>
|
||||||
</Container>
|
</Container>
|
||||||
</Body>
|
</Body>
|
||||||
</Tailwind>
|
</Tailwind>
|
||||||
|
|
|
@ -63,6 +63,11 @@ export const VerifyEmail = ({
|
||||||
If you didn’t request this, you can safely ignore
|
If you didn’t request this, you can safely ignore
|
||||||
this email.
|
this email.
|
||||||
</Text>
|
</Text>
|
||||||
|
<Text className="text-sm text-gray-500 mt-6">
|
||||||
|
Best regards,
|
||||||
|
<br />
|
||||||
|
Fossorial
|
||||||
|
</Text>
|
||||||
</Container>
|
</Container>
|
||||||
</Body>
|
</Body>
|
||||||
</Tailwind>
|
</Tailwind>
|
||||||
|
|
|
@ -19,6 +19,7 @@ import { z } from "zod";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { createDate, TimeSpan } from "oslo";
|
import { createDate, TimeSpan } from "oslo";
|
||||||
|
import { hashPassword } from "@server/auth/password";
|
||||||
|
|
||||||
export const generateAccessTokenBodySchema = z
|
export const generateAccessTokenBodySchema = z
|
||||||
.object({
|
.object({
|
||||||
|
@ -91,12 +92,7 @@ export async function generateAccessToken(
|
||||||
|
|
||||||
const token = generateIdFromEntropySize(25);
|
const token = generateIdFromEntropySize(25);
|
||||||
|
|
||||||
const tokenHash = await hash(token, {
|
const tokenHash = await hashPassword(token);
|
||||||
memoryCost: 19456,
|
|
||||||
timeCost: 2,
|
|
||||||
outputLen: 32,
|
|
||||||
parallelism: 1
|
|
||||||
});
|
|
||||||
|
|
||||||
const id = generateId(15);
|
const id = generateId(15);
|
||||||
const [result] = await db
|
const [result] = await db
|
||||||
|
|
|
@ -3,7 +3,7 @@ import {
|
||||||
createSession,
|
createSession,
|
||||||
generateSessionToken,
|
generateSessionToken,
|
||||||
serializeSessionCookie,
|
serializeSessionCookie,
|
||||||
verifySession,
|
verifySession
|
||||||
} from "@server/auth";
|
} from "@server/auth";
|
||||||
import db from "@server/db";
|
import db from "@server/db";
|
||||||
import { users } from "@server/db/schema";
|
import { users } from "@server/db/schema";
|
||||||
|
@ -17,12 +17,15 @@ import { fromError } from "zod-validation-error";
|
||||||
import { verifyTotpCode } from "@server/auth/2fa";
|
import { verifyTotpCode } from "@server/auth/2fa";
|
||||||
import config from "@server/config";
|
import config from "@server/config";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
|
import { verifyPassword } from "@server/auth/password";
|
||||||
|
|
||||||
export const loginBodySchema = z.object({
|
export const loginBodySchema = z
|
||||||
|
.object({
|
||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
password: z.string(),
|
password: z.string(),
|
||||||
code: z.string().optional(),
|
code: z.string().optional()
|
||||||
}).strict();
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
export type LoginBody = z.infer<typeof loginBodySchema>;
|
export type LoginBody = z.infer<typeof loginBodySchema>;
|
||||||
|
|
||||||
|
@ -57,7 +60,7 @@ export async function login(
|
||||||
success: true,
|
success: true,
|
||||||
error: false,
|
error: false,
|
||||||
message: "Already logged in",
|
message: "Already logged in",
|
||||||
status: HttpCode.OK,
|
status: HttpCode.OK
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -76,15 +79,9 @@ export async function login(
|
||||||
|
|
||||||
const existingUser = existingUserRes[0];
|
const existingUser = existingUserRes[0];
|
||||||
|
|
||||||
const validPassword = await verify(
|
const validPassword = await verifyPassword(
|
||||||
existingUser.passwordHash,
|
|
||||||
password,
|
password,
|
||||||
{
|
existingUser.passwordHash
|
||||||
memoryCost: 19456,
|
|
||||||
timeCost: 2,
|
|
||||||
outputLen: 32,
|
|
||||||
parallelism: 1,
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
if (!validPassword) {
|
if (!validPassword) {
|
||||||
return next(
|
return next(
|
||||||
|
@ -102,7 +99,7 @@ export async function login(
|
||||||
success: true,
|
success: true,
|
||||||
error: false,
|
error: false,
|
||||||
message: "Two-factor authentication required",
|
message: "Two-factor authentication required",
|
||||||
status: HttpCode.ACCEPTED,
|
status: HttpCode.ACCEPTED
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -137,7 +134,7 @@ export async function login(
|
||||||
success: true,
|
success: true,
|
||||||
error: false,
|
error: false,
|
||||||
message: "Email verification code sent",
|
message: "Email verification code sent",
|
||||||
status: HttpCode.OK,
|
status: HttpCode.OK
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -146,7 +143,7 @@ export async function login(
|
||||||
success: true,
|
success: true,
|
||||||
error: false,
|
error: false,
|
||||||
message: "Logged in successfully",
|
message: "Logged in successfully",
|
||||||
status: HttpCode.OK,
|
status: HttpCode.OK
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(e);
|
logger.error(e);
|
||||||
|
|
|
@ -7,16 +7,22 @@ import { response } from "@server/utils";
|
||||||
import { db } from "@server/db";
|
import { db } from "@server/db";
|
||||||
import { passwordResetTokens, users } from "@server/db/schema";
|
import { passwordResetTokens, users } from "@server/db/schema";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { sha256 } from "oslo/crypto";
|
import { alphabet, generateRandomString, sha256 } from "oslo/crypto";
|
||||||
import { encodeHex } from "oslo/encoding";
|
import { encodeHex } from "oslo/encoding";
|
||||||
import { createDate } from "oslo";
|
import { createDate } from "oslo";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { generateIdFromEntropySize } from "@server/auth";
|
import { generateIdFromEntropySize } from "@server/auth";
|
||||||
import { TimeSpan } from "oslo";
|
import { TimeSpan } from "oslo";
|
||||||
|
import config from "@server/config";
|
||||||
|
import { sendEmail } from "@server/emails";
|
||||||
|
import ResetPasswordCode from "@server/emails/templates/ResetPasswordCode";
|
||||||
|
import { hashPassword } from "@server/auth/password";
|
||||||
|
|
||||||
export const requestPasswordResetBody = z.object({
|
export const requestPasswordResetBody = z
|
||||||
email: z.string().email(),
|
.object({
|
||||||
}).strict();
|
email: z.string().email()
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
export type RequestPasswordResetBody = z.infer<typeof requestPasswordResetBody>;
|
export type RequestPasswordResetBody = z.infer<typeof requestPasswordResetBody>;
|
||||||
|
|
||||||
|
@ -27,7 +33,7 @@ export type RequestPasswordResetResponse = {
|
||||||
export async function requestPasswordReset(
|
export async function requestPasswordReset(
|
||||||
req: Request,
|
req: Request,
|
||||||
res: Response,
|
res: Response,
|
||||||
next: NextFunction,
|
next: NextFunction
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
const parsedBody = requestPasswordResetBody.safeParse(req.body);
|
const parsedBody = requestPasswordResetBody.safeParse(req.body);
|
||||||
|
|
||||||
|
@ -35,8 +41,8 @@ export async function requestPasswordReset(
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.BAD_REQUEST,
|
HttpCode.BAD_REQUEST,
|
||||||
fromError(parsedBody.error).toString(),
|
fromError(parsedBody.error).toString()
|
||||||
),
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -52,8 +58,8 @@ export async function requestPasswordReset(
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.BAD_REQUEST,
|
HttpCode.BAD_REQUEST,
|
||||||
"No user with that email exists",
|
"A user with that email does not exist"
|
||||||
),
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -61,36 +67,47 @@ export async function requestPasswordReset(
|
||||||
.delete(passwordResetTokens)
|
.delete(passwordResetTokens)
|
||||||
.where(eq(passwordResetTokens.userId, existingUser[0].userId));
|
.where(eq(passwordResetTokens.userId, existingUser[0].userId));
|
||||||
|
|
||||||
const token = generateIdFromEntropySize(25);
|
const token = generateRandomString(8, alphabet("0-9", "A-Z", "a-z"));
|
||||||
const tokenHash = encodeHex(
|
const tokenHash = await hashPassword(token);
|
||||||
await sha256(new TextEncoder().encode(token)),
|
|
||||||
);
|
|
||||||
|
|
||||||
await db.insert(passwordResetTokens).values({
|
await db.insert(passwordResetTokens).values({
|
||||||
userId: existingUser[0].userId,
|
userId: existingUser[0].userId,
|
||||||
|
email: existingUser[0].email,
|
||||||
tokenHash,
|
tokenHash,
|
||||||
expiresAt: createDate(new TimeSpan(2, "h")).getTime(),
|
expiresAt: createDate(new TimeSpan(2, "h")).getTime()
|
||||||
});
|
});
|
||||||
|
|
||||||
// TODO: send email with link to reset password on dashboard
|
const url = `${config.app.base_url}/auth/reset-password?email=${email}&token=${token}`;
|
||||||
// something like: https://example.com/auth/reset-password?email=${email}&?token=${token}
|
|
||||||
// for now, just log the token
|
await sendEmail(
|
||||||
|
ResetPasswordCode({
|
||||||
|
email,
|
||||||
|
code: token,
|
||||||
|
link: url
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
from: config.email?.no_reply,
|
||||||
|
to: email,
|
||||||
|
subject: "Reset your password"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
return response<RequestPasswordResetResponse>(res, {
|
return response<RequestPasswordResetResponse>(res, {
|
||||||
data: {
|
data: {
|
||||||
sentEmail: true,
|
sentEmail: true
|
||||||
},
|
},
|
||||||
success: true,
|
success: true,
|
||||||
error: false,
|
error: false,
|
||||||
message: "Password reset email sent",
|
message: "Password reset requested",
|
||||||
status: HttpCode.OK,
|
status: HttpCode.OK
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(e);
|
logger.error(e);
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.INTERNAL_SERVER_ERROR,
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
"Failed to process password reset request",
|
"Failed to process password reset request"
|
||||||
),
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,6 +13,7 @@ import { verify } from "@node-rs/argon2";
|
||||||
import { createTOTPKeyURI } from "oslo/otp";
|
import { createTOTPKeyURI } from "oslo/otp";
|
||||||
import config from "@server/config";
|
import config from "@server/config";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
|
import { verifyPassword } from "@server/auth/password";
|
||||||
|
|
||||||
export const requestTotpSecretBody = z
|
export const requestTotpSecretBody = z
|
||||||
.object({
|
.object({
|
||||||
|
@ -47,12 +48,7 @@ export async function requestTotpSecret(
|
||||||
const user = req.user as User;
|
const user = req.user as User;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const validPassword = await verify(user.passwordHash, password, {
|
const validPassword = await verifyPassword(password, user.passwordHash);
|
||||||
memoryCost: 19456,
|
|
||||||
timeCost: 2,
|
|
||||||
outputLen: 32,
|
|
||||||
parallelism: 1
|
|
||||||
});
|
|
||||||
if (!validPassword) {
|
if (!validPassword) {
|
||||||
return next(unauthorized());
|
return next(unauthorized());
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import config from "@server/config";
|
||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
@ -8,19 +9,22 @@ import { db } from "@server/db";
|
||||||
import { passwordResetTokens, users } from "@server/db/schema";
|
import { passwordResetTokens, users } from "@server/db/schema";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { sha256 } from "oslo/crypto";
|
import { sha256 } from "oslo/crypto";
|
||||||
import { hashPassword } from "@server/auth/password";
|
import { hashPassword, verifyPassword } from "@server/auth/password";
|
||||||
import { verifyTotpCode } from "@server/auth/2fa";
|
import { verifyTotpCode } from "@server/auth/2fa";
|
||||||
import { passwordSchema } from "@server/auth/passwordSchema";
|
import { passwordSchema } from "@server/auth/passwordSchema";
|
||||||
import { encodeHex } from "oslo/encoding";
|
import { encodeHex } from "oslo/encoding";
|
||||||
import { isWithinExpirationDate } from "oslo";
|
import { isWithinExpirationDate } from "oslo";
|
||||||
import { invalidateAllSessions } from "@server/auth";
|
import { invalidateAllSessions } from "@server/auth";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
|
import ConfirmPasswordReset from "@server/emails/templates/NotifyResetPassword";
|
||||||
|
import { sendEmail } from "@server/emails";
|
||||||
|
|
||||||
export const resetPasswordBody = z
|
export const resetPasswordBody = z
|
||||||
.object({
|
.object({
|
||||||
token: z.string(),
|
email: z.string().email(),
|
||||||
|
token: z.string(), // reset secret code
|
||||||
newPassword: passwordSchema,
|
newPassword: passwordSchema,
|
||||||
code: z.string().optional()
|
code: z.string().optional() // 2fa code
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
|
@ -46,27 +50,28 @@ export async function resetPassword(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { token, newPassword, code } = parsedBody.data;
|
const { token, newPassword, code, email } = parsedBody.data;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const tokenHash = encodeHex(
|
|
||||||
await sha256(new TextEncoder().encode(token))
|
|
||||||
);
|
|
||||||
|
|
||||||
const resetRequest = await db
|
const resetRequest = await db
|
||||||
.select()
|
.select()
|
||||||
.from(passwordResetTokens)
|
.from(passwordResetTokens)
|
||||||
.where(eq(passwordResetTokens.tokenHash, tokenHash));
|
.where(eq(passwordResetTokens.email, email));
|
||||||
|
|
||||||
if (
|
if (!resetRequest || !resetRequest.length) {
|
||||||
!resetRequest ||
|
|
||||||
!resetRequest.length ||
|
|
||||||
!isWithinExpirationDate(new Date(resetRequest[0].expiresAt))
|
|
||||||
) {
|
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.BAD_REQUEST,
|
HttpCode.BAD_REQUEST,
|
||||||
"Invalid or expired password reset token"
|
"Invalid password reset token"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isWithinExpirationDate(new Date(resetRequest[0].expiresAt))) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Password reset token has expired"
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -112,6 +117,20 @@ export async function resetPassword(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isTokenValid = await verifyPassword(
|
||||||
|
token,
|
||||||
|
resetRequest[0].tokenHash
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isTokenValid) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Invalid password reset token"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const passwordHash = await hashPassword(newPassword);
|
const passwordHash = await hashPassword(newPassword);
|
||||||
|
|
||||||
await invalidateAllSessions(resetRequest[0].userId);
|
await invalidateAllSessions(resetRequest[0].userId);
|
||||||
|
@ -123,9 +142,13 @@ export async function resetPassword(
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.delete(passwordResetTokens)
|
.delete(passwordResetTokens)
|
||||||
.where(eq(passwordResetTokens.tokenHash, tokenHash));
|
.where(eq(passwordResetTokens.email, email));
|
||||||
|
|
||||||
// TODO: send email to user confirming password reset
|
await sendEmail(ConfirmPasswordReset({ email }), {
|
||||||
|
from: config.email?.no_reply,
|
||||||
|
to: email,
|
||||||
|
subject: "Password Reset Confirmation"
|
||||||
|
})
|
||||||
|
|
||||||
return response<ResetPasswordResponse>(res, {
|
return response<ResetPasswordResponse>(res, {
|
||||||
data: null,
|
data: null,
|
||||||
|
|
|
@ -21,6 +21,7 @@ import {
|
||||||
import { ActionsEnum } from "@server/auth/actions";
|
import { ActionsEnum } from "@server/auth/actions";
|
||||||
import config from "@server/config";
|
import config from "@server/config";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
|
import { hashPassword } from "@server/auth/password";
|
||||||
|
|
||||||
export const signupBodySchema = z.object({
|
export const signupBodySchema = z.object({
|
||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
|
@ -51,12 +52,7 @@ export async function signup(
|
||||||
|
|
||||||
const { email, password } = parsedBody.data;
|
const { email, password } = parsedBody.data;
|
||||||
|
|
||||||
const passwordHash = await hash(password, {
|
const passwordHash = await hashPassword(password);
|
||||||
memoryCost: 19456,
|
|
||||||
timeCost: 2,
|
|
||||||
outputLen: 32,
|
|
||||||
parallelism: 1,
|
|
||||||
});
|
|
||||||
const userId = generateId(15);
|
const userId = generateId(15);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
|
@ -92,6 +92,15 @@ export async function verifyTotp(
|
||||||
|
|
||||||
// TODO: send email to user confirming two-factor authentication is enabled
|
// TODO: send email to user confirming two-factor authentication is enabled
|
||||||
|
|
||||||
|
if (!valid) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Invalid two-factor authentication code"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return response<VerifyTotpResponse>(res, {
|
return response<VerifyTotpResponse>(res, {
|
||||||
data: {
|
data: {
|
||||||
valid,
|
valid,
|
||||||
|
@ -118,7 +127,7 @@ export async function verifyTotp(
|
||||||
async function generateBackupCodes(): Promise<string[]> {
|
async function generateBackupCodes(): Promise<string[]> {
|
||||||
const codes = [];
|
const codes = [];
|
||||||
for (let i = 0; i < 10; i++) {
|
for (let i = 0; i < 10; i++) {
|
||||||
const code = generateRandomString(8, alphabet("0-9", "A-Z", "a-z"));
|
const code = generateRandomString(6, alphabet("0-9", "A-Z", "a-z"));
|
||||||
codes.push(code);
|
codes.push(code);
|
||||||
}
|
}
|
||||||
return codes;
|
return codes;
|
||||||
|
|
|
@ -448,11 +448,11 @@ authRouter.post(
|
||||||
verifySessionMiddleware,
|
verifySessionMiddleware,
|
||||||
auth.requestEmailVerificationCode
|
auth.requestEmailVerificationCode
|
||||||
);
|
);
|
||||||
authRouter.post(
|
// authRouter.post(
|
||||||
"/change-password",
|
// "/change-password",
|
||||||
verifySessionUserMiddleware,
|
// verifySessionUserMiddleware,
|
||||||
auth.changePassword
|
// auth.changePassword
|
||||||
);
|
// );
|
||||||
authRouter.post("/reset-password/request", auth.requestPasswordReset);
|
authRouter.post("/reset-password/request", auth.requestPasswordReset);
|
||||||
authRouter.post("/reset-password/", auth.resetPassword);
|
authRouter.post("/reset-password/", auth.resetPassword);
|
||||||
|
|
||||||
|
|
|
@ -11,6 +11,7 @@ import moment from "moment";
|
||||||
import { generateSessionToken } from "@server/auth";
|
import { generateSessionToken } from "@server/auth";
|
||||||
import { createNewtSession } from "@server/auth/newt";
|
import { createNewtSession } from "@server/auth/newt";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
|
import { hashPassword } from "@server/auth/password";
|
||||||
|
|
||||||
export const createNewtBodySchema = z.object({});
|
export const createNewtBodySchema = z.object({});
|
||||||
|
|
||||||
|
@ -54,13 +55,7 @@ export async function createNewt(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// generate a newtId and secret
|
const secretHash = await hashPassword(secret);
|
||||||
const secretHash = await hash(secret, {
|
|
||||||
memoryCost: 19456,
|
|
||||||
timeCost: 2,
|
|
||||||
outputLen: 32,
|
|
||||||
parallelism: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
await db.insert(newts).values({
|
await db.insert(newts).values({
|
||||||
newtId: newtId,
|
newtId: newtId,
|
||||||
|
|
|
@ -2,7 +2,7 @@ import { verify } from "@node-rs/argon2";
|
||||||
import {
|
import {
|
||||||
createSession,
|
createSession,
|
||||||
generateSessionToken,
|
generateSessionToken,
|
||||||
verifySession,
|
verifySession
|
||||||
} from "@server/auth";
|
} from "@server/auth";
|
||||||
import db from "@server/db";
|
import db from "@server/db";
|
||||||
import { newts } from "@server/db/schema";
|
import { newts } from "@server/db/schema";
|
||||||
|
@ -14,11 +14,12 @@ import createHttpError from "http-errors";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { createNewtSession, validateNewtSessionToken } from "@server/auth/newt";
|
import { createNewtSession, validateNewtSessionToken } from "@server/auth/newt";
|
||||||
|
import { verifyPassword } from "@server/auth/password";
|
||||||
|
|
||||||
export const newtGetTokenBodySchema = z.object({
|
export const newtGetTokenBodySchema = z.object({
|
||||||
newtId: z.string(),
|
newtId: z.string(),
|
||||||
secret: z.string(),
|
secret: z.string(),
|
||||||
token: z.string().optional(),
|
token: z.string().optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
export type NewtGetTokenBody = z.infer<typeof newtGetTokenBodySchema>;
|
export type NewtGetTokenBody = z.infer<typeof newtGetTokenBodySchema>;
|
||||||
|
@ -43,16 +44,14 @@ export async function getToken(
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (token) {
|
if (token) {
|
||||||
const { session, newt } = await validateNewtSessionToken(
|
const { session, newt } = await validateNewtSessionToken(token);
|
||||||
token
|
|
||||||
);
|
|
||||||
if (session) {
|
if (session) {
|
||||||
return response<null>(res, {
|
return response<null>(res, {
|
||||||
data: null,
|
data: null,
|
||||||
success: true,
|
success: true,
|
||||||
error: false,
|
error: false,
|
||||||
message: "Token session already valid",
|
message: "Token session already valid",
|
||||||
status: HttpCode.OK,
|
status: HttpCode.OK
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -72,22 +71,13 @@ export async function getToken(
|
||||||
|
|
||||||
const existingNewt = existingNewtRes[0];
|
const existingNewt = existingNewtRes[0];
|
||||||
|
|
||||||
const validSecret = await verify(
|
const validSecret = await verifyPassword(
|
||||||
existingNewt.secretHash,
|
|
||||||
secret,
|
secret,
|
||||||
{
|
existingNewt.secretHash
|
||||||
memoryCost: 19456,
|
|
||||||
timeCost: 2,
|
|
||||||
outputLen: 32,
|
|
||||||
parallelism: 1,
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
if (!validSecret) {
|
if (!validSecret) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(HttpCode.BAD_REQUEST, "Secret is incorrect")
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
"Secret is incorrect"
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -101,7 +91,7 @@ export async function getToken(
|
||||||
success: true,
|
success: true,
|
||||||
error: false,
|
error: false,
|
||||||
message: "Token created successfully",
|
message: "Token created successfully",
|
||||||
status: HttpCode.OK,
|
status: HttpCode.OK
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
|
|
|
@ -16,6 +16,7 @@ import config from "@server/config";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { verify } from "@node-rs/argon2";
|
import { verify } from "@node-rs/argon2";
|
||||||
import { isWithinExpirationDate } from "oslo";
|
import { isWithinExpirationDate } from "oslo";
|
||||||
|
import { verifyPassword } from "@server/auth/password";
|
||||||
|
|
||||||
const authWithAccessTokenBodySchema = z
|
const authWithAccessTokenBodySchema = z
|
||||||
.object({
|
.object({
|
||||||
|
@ -104,12 +105,8 @@ export async function authWithAccessToken(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const validCode = await verify(tokenItem.tokenHash, accessToken, {
|
const validCode = await verifyPassword(tokenItem.tokenHash, accessToken);
|
||||||
memoryCost: 19456,
|
|
||||||
timeCost: 2,
|
|
||||||
outputLen: 32,
|
|
||||||
parallelism: 1
|
|
||||||
});
|
|
||||||
if (!validCode) {
|
if (!validCode) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(HttpCode.UNAUTHORIZED, "Invalid access token")
|
createHttpError(HttpCode.UNAUTHORIZED, "Invalid access token")
|
||||||
|
|
|
@ -15,6 +15,7 @@ import {
|
||||||
} from "@server/auth/resource";
|
} from "@server/auth/resource";
|
||||||
import config from "@server/config";
|
import config from "@server/config";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
|
import { verifyPassword } from "@server/auth/password";
|
||||||
|
|
||||||
export const authWithPasswordBodySchema = z
|
export const authWithPasswordBodySchema = z
|
||||||
.object({
|
.object({
|
||||||
|
@ -105,15 +106,9 @@ export async function authWithPassword(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const validPassword = await verify(
|
const validPassword = await verifyPassword(
|
||||||
definedPassword.passwordHash,
|
|
||||||
password,
|
password,
|
||||||
{
|
definedPassword.passwordHash
|
||||||
memoryCost: 19456,
|
|
||||||
timeCost: 2,
|
|
||||||
outputLen: 32,
|
|
||||||
parallelism: 1
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
if (!validPassword) {
|
if (!validPassword) {
|
||||||
return next(
|
return next(
|
||||||
|
|
|
@ -23,6 +23,7 @@ import logger from "@server/logger";
|
||||||
import config from "@server/config";
|
import config from "@server/config";
|
||||||
import { AuthWithPasswordResponse } from "./authWithPassword";
|
import { AuthWithPasswordResponse } from "./authWithPassword";
|
||||||
import { isValidOtp, sendResourceOtpEmail } from "@server/auth/resourceOtp";
|
import { isValidOtp, sendResourceOtpEmail } from "@server/auth/resourceOtp";
|
||||||
|
import { verifyPassword } from "@server/auth/password";
|
||||||
|
|
||||||
export const authWithPincodeBodySchema = z
|
export const authWithPincodeBodySchema = z
|
||||||
.object({
|
.object({
|
||||||
|
@ -116,12 +117,10 @@ export async function authWithPincode(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const validPincode = await verify(definedPincode.pincodeHash, pincode, {
|
const validPincode = verifyPassword(
|
||||||
memoryCost: 19456,
|
pincode,
|
||||||
timeCost: 2,
|
definedPincode.pincodeHash
|
||||||
outputLen: 32,
|
);
|
||||||
parallelism: 1
|
|
||||||
});
|
|
||||||
if (!validPincode) {
|
if (!validPincode) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(HttpCode.UNAUTHORIZED, "Incorrect PIN")
|
createHttpError(HttpCode.UNAUTHORIZED, "Incorrect PIN")
|
||||||
|
|
|
@ -9,6 +9,7 @@ import { fromError } from "zod-validation-error";
|
||||||
import { hash } from "@node-rs/argon2";
|
import { hash } from "@node-rs/argon2";
|
||||||
import { response } from "@server/utils";
|
import { response } from "@server/utils";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
|
import { hashPassword } from "@server/auth/password";
|
||||||
|
|
||||||
const setResourceAuthMethodsParamsSchema = z.object({
|
const setResourceAuthMethodsParamsSchema = z.object({
|
||||||
resourceId: z.string().transform(Number).pipe(z.number().int().positive())
|
resourceId: z.string().transform(Number).pipe(z.number().int().positive())
|
||||||
|
@ -57,12 +58,7 @@ export async function setResourcePassword(
|
||||||
.where(eq(resourcePassword.resourceId, resourceId));
|
.where(eq(resourcePassword.resourceId, resourceId));
|
||||||
|
|
||||||
if (password) {
|
if (password) {
|
||||||
const passwordHash = await hash(password, {
|
const passwordHash = await hashPassword(password);
|
||||||
memoryCost: 19456,
|
|
||||||
timeCost: 2,
|
|
||||||
outputLen: 32,
|
|
||||||
parallelism: 1
|
|
||||||
});
|
|
||||||
|
|
||||||
await trx
|
await trx
|
||||||
.insert(resourcePassword)
|
.insert(resourcePassword)
|
||||||
|
|
|
@ -10,6 +10,7 @@ import { hash } from "@node-rs/argon2";
|
||||||
import { response } from "@server/utils";
|
import { response } from "@server/utils";
|
||||||
import stoi from "@server/utils/stoi";
|
import stoi from "@server/utils/stoi";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
|
import { hashPassword } from "@server/auth/password";
|
||||||
|
|
||||||
const setResourceAuthMethodsParamsSchema = z.object({
|
const setResourceAuthMethodsParamsSchema = z.object({
|
||||||
resourceId: z.string().transform(Number).pipe(z.number().int().positive()),
|
resourceId: z.string().transform(Number).pipe(z.number().int().positive()),
|
||||||
|
@ -61,12 +62,7 @@ export async function setResourcePincode(
|
||||||
.where(eq(resourcePincode.resourceId, resourceId));
|
.where(eq(resourcePincode.resourceId, resourceId));
|
||||||
|
|
||||||
if (pincode) {
|
if (pincode) {
|
||||||
const pincodeHash = await hash(pincode, {
|
const pincodeHash = await hashPassword(pincode);
|
||||||
memoryCost: 19456,
|
|
||||||
timeCost: 2,
|
|
||||||
outputLen: 32,
|
|
||||||
parallelism: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
await trx
|
await trx
|
||||||
.insert(resourcePincode)
|
.insert(resourcePincode)
|
||||||
|
|
|
@ -13,6 +13,7 @@ import { fromError } from "zod-validation-error";
|
||||||
import { hash } from "@node-rs/argon2";
|
import { hash } from "@node-rs/argon2";
|
||||||
import { newts } from "@server/db/schema";
|
import { newts } from "@server/db/schema";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
|
import { hashPassword } from "@server/auth/password";
|
||||||
|
|
||||||
const createSiteParamsSchema = z
|
const createSiteParamsSchema = z
|
||||||
.object({
|
.object({
|
||||||
|
@ -122,12 +123,7 @@ export async function createSite(
|
||||||
|
|
||||||
// add the peer to the exit node
|
// add the peer to the exit node
|
||||||
if (type == "newt") {
|
if (type == "newt") {
|
||||||
const secretHash = await hash(secret!, {
|
const secretHash = await hashPassword(secret!);
|
||||||
memoryCost: 19456,
|
|
||||||
timeCost: 2,
|
|
||||||
outputLen: 32,
|
|
||||||
parallelism: 1
|
|
||||||
});
|
|
||||||
|
|
||||||
await db.insert(newts).values({
|
await db.insert(newts).values({
|
||||||
newtId: newtId!,
|
newtId: newtId!,
|
||||||
|
|
|
@ -10,6 +10,7 @@ import createHttpError from "http-errors";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { isWithinExpirationDate } from "oslo";
|
import { isWithinExpirationDate } from "oslo";
|
||||||
|
import { verifyPassword } from "@server/auth/password";
|
||||||
|
|
||||||
const acceptInviteBodySchema = z
|
const acceptInviteBodySchema = z
|
||||||
.object({
|
.object({
|
||||||
|
@ -62,12 +63,10 @@ export async function acceptInvite(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const validToken = await verify(existingInvite[0].tokenHash, token, {
|
const validToken = await verifyPassword(
|
||||||
memoryCost: 19456,
|
token,
|
||||||
timeCost: 2,
|
existingInvite[0].tokenHash
|
||||||
outputLen: 32,
|
);
|
||||||
parallelism: 1
|
|
||||||
});
|
|
||||||
if (!validToken) {
|
if (!validToken) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
|
|
|
@ -30,8 +30,8 @@ export default async function OrgLayout(props: {
|
||||||
const getOrgUser = cache(() =>
|
const getOrgUser = cache(() =>
|
||||||
internal.get<AxiosResponse<GetOrgUserResponse>>(
|
internal.get<AxiosResponse<GetOrgUserResponse>>(
|
||||||
`/org/${orgId}/user/${user.userId}`,
|
`/org/${orgId}/user/${user.userId}`,
|
||||||
cookie,
|
cookie
|
||||||
),
|
)
|
||||||
);
|
);
|
||||||
const orgUser = await getOrgUser();
|
const orgUser = await getOrgUser();
|
||||||
} catch {
|
} catch {
|
||||||
|
@ -40,10 +40,7 @@ export default async function OrgLayout(props: {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const getOrg = cache(() =>
|
const getOrg = cache(() =>
|
||||||
internal.get<AxiosResponse<GetOrgResponse>>(
|
internal.get<AxiosResponse<GetOrgResponse>>(`/org/${orgId}`, cookie)
|
||||||
`/org/${orgId}`,
|
|
||||||
cookie,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
await getOrg();
|
await getOrg();
|
||||||
} catch {
|
} catch {
|
||||||
|
|
|
@ -126,7 +126,7 @@ export default function CreateRoleForm({
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
className="space-y-8"
|
className="space-y-4"
|
||||||
id="create-role-form"
|
id="create-role-form"
|
||||||
>
|
>
|
||||||
<FormField
|
<FormField
|
||||||
|
|
|
@ -173,7 +173,7 @@ export default function DeleteRoleForm({
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
className="space-y-8"
|
className="space-y-4"
|
||||||
id="remove-role-form"
|
id="remove-role-form"
|
||||||
>
|
>
|
||||||
<FormField
|
<FormField
|
||||||
|
|
|
@ -123,7 +123,7 @@ export default function AccessControlsPage() {
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
className="space-y-8"
|
className="space-y-4"
|
||||||
>
|
>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
|
|
|
@ -1,221 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { createApiClient } from "@app/api";
|
|
||||||
import { Avatar, AvatarFallback } from "@app/components/ui/avatar";
|
|
||||||
import { Button } from "@app/components/ui/button";
|
|
||||||
import {
|
|
||||||
Command,
|
|
||||||
CommandEmpty,
|
|
||||||
CommandGroup,
|
|
||||||
CommandInput,
|
|
||||||
CommandItem,
|
|
||||||
CommandList,
|
|
||||||
} from "@app/components/ui/command";
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuGroup,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuLabel,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@app/components/ui/dropdown-menu";
|
|
||||||
import {
|
|
||||||
Popover,
|
|
||||||
PopoverContent,
|
|
||||||
PopoverTrigger,
|
|
||||||
} from "@app/components/ui/popover";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectGroup,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@app/components/ui/select";
|
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
|
||||||
import { useToast } from "@app/hooks/useToast";
|
|
||||||
import { cn, formatAxiosError } from "@app/lib/utils";
|
|
||||||
import { ListOrgsResponse } from "@server/routers/org";
|
|
||||||
import { Check, ChevronsUpDown, Plus } from "lucide-react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
type HeaderProps = {
|
|
||||||
name?: string;
|
|
||||||
email: string;
|
|
||||||
orgId: string;
|
|
||||||
orgs: ListOrgsResponse["orgs"];
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function Header({ email, orgId, name, orgs }: HeaderProps) {
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const api = createApiClient(useEnvContext());
|
|
||||||
|
|
||||||
function getInitials() {
|
|
||||||
if (name) {
|
|
||||||
const [firstName, lastName] = name.split(" ");
|
|
||||||
return `${firstName[0]}${lastName[0]}`;
|
|
||||||
}
|
|
||||||
return email.substring(0, 2).toUpperCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
function logout() {
|
|
||||||
api.post("/auth/logout")
|
|
||||||
.catch((e) => {
|
|
||||||
console.error("Error logging out", e);
|
|
||||||
toast({
|
|
||||||
title: "Error logging out",
|
|
||||||
description: formatAxiosError(e, "Error logging out"),
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
router.push("/auth/login");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="relative h-10 w-10 rounded-full"
|
|
||||||
>
|
|
||||||
<Avatar className="h-9 w-9">
|
|
||||||
<AvatarFallback>
|
|
||||||
{getInitials()}
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent
|
|
||||||
className="w-56"
|
|
||||||
align="start"
|
|
||||||
forceMount
|
|
||||||
>
|
|
||||||
<DropdownMenuLabel className="font-normal">
|
|
||||||
<div className="flex flex-col space-y-1">
|
|
||||||
{name && (
|
|
||||||
<p className="text-sm font-medium leading-none truncate">
|
|
||||||
{name}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<p className="text-xs leading-none text-muted-foreground truncate">
|
|
||||||
{email}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</DropdownMenuLabel>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuGroup>
|
|
||||||
<DropdownMenuItem onClick={logout}>
|
|
||||||
Logout
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
<span className="truncate max-w-[150px] md:max-w-none font-medium">
|
|
||||||
{name || email}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center">
|
|
||||||
<div className="hidden md:block">
|
|
||||||
<div className="flex items-center gap-4 mr-4">
|
|
||||||
<Link
|
|
||||||
href="/docs"
|
|
||||||
className="text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
Documentation
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/support"
|
|
||||||
className="text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
Support
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Popover open={open} onOpenChange={setOpen}>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="lg"
|
|
||||||
role="combobox"
|
|
||||||
aria-expanded={open}
|
|
||||||
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">
|
|
||||||
<span className="font-bold text-sm">
|
|
||||||
Organization
|
|
||||||
</span>
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
{orgId
|
|
||||||
? orgs.find(
|
|
||||||
(org) =>
|
|
||||||
org.orgId === orgId,
|
|
||||||
)?.name
|
|
||||||
: "Select organization..."}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" />
|
|
||||||
</div>
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="[100px] md:w-[180px] p-0">
|
|
||||||
<Command>
|
|
||||||
<CommandInput placeholder="Search..." />
|
|
||||||
<CommandEmpty>
|
|
||||||
No organization found.
|
|
||||||
</CommandEmpty>
|
|
||||||
<CommandGroup className="[50px]">
|
|
||||||
<CommandList>
|
|
||||||
<CommandItem
|
|
||||||
className="flex items-center border border-input mb-2 cursor-pointer"
|
|
||||||
onSelect={(currentValue) => {
|
|
||||||
router.push("/setup");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Plus className="mr-2 h-4 w-4"/>
|
|
||||||
New Organization
|
|
||||||
</CommandItem>
|
|
||||||
{orgs.map((org) => (
|
|
||||||
<CommandItem
|
|
||||||
key={org.orgId}
|
|
||||||
onSelect={(currentValue) => {
|
|
||||||
router.push(
|
|
||||||
`/${org.orgId}/settings`,
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Check
|
|
||||||
className={cn(
|
|
||||||
"mr-2 h-4 w-4",
|
|
||||||
orgId === org.orgId
|
|
||||||
? "opacity-100"
|
|
||||||
: "opacity-0",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
{org.name}
|
|
||||||
</CommandItem>
|
|
||||||
))}
|
|
||||||
</CommandList>
|
|
||||||
</CommandGroup>
|
|
||||||
</Command>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -57,12 +57,11 @@ export default function GeneralPage() {
|
||||||
|
|
||||||
async function deleteOrg() {
|
async function deleteOrg() {
|
||||||
try {
|
try {
|
||||||
|
const res = await api.delete<AxiosResponse<DeleteOrgResponse>>(
|
||||||
const res = await api
|
`/org/${org?.org.orgId}`
|
||||||
.delete<AxiosResponse<DeleteOrgResponse>>(`/org/${org?.org.orgId}`);
|
);
|
||||||
if (res.status === 200) {
|
if (res.status === 200) {
|
||||||
console.log("Org deleted");
|
console.log("Org deleted");
|
||||||
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
@ -72,7 +71,7 @@ export default function GeneralPage() {
|
||||||
description: formatAxiosError(
|
description: formatAxiosError(
|
||||||
err,
|
err,
|
||||||
"An error occurred while deleting the org."
|
"An error occurred while deleting the org."
|
||||||
),
|
)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -118,16 +117,17 @@ export default function GeneralPage() {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
buttonText="Confirm delete organization"
|
buttonText="Confirm Delete Organization"
|
||||||
onConfirm={deleteOrg}
|
onConfirm={deleteOrg}
|
||||||
string={org?.org.name || ""}
|
string={org?.org.name || ""}
|
||||||
title="Delete organization"
|
title="Delete Organization"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<section className="space-y-8 max-w-lg">
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
className="space-y-8 max-w-lg"
|
className="space-y-4"
|
||||||
>
|
>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
|
@ -149,7 +149,7 @@ export default function GeneralPage() {
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
|
|
||||||
<Card className="max-w-lg border-red-900 mt-5">
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2 text-red-600">
|
<CardTitle className="flex items-center gap-2 text-red-600">
|
||||||
<AlertTriangle className="h-5 w-5" />
|
<AlertTriangle className="h-5 w-5" />
|
||||||
|
@ -157,9 +157,9 @@ export default function GeneralPage() {
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="text-sm mb-4">
|
<p className="text-sm">
|
||||||
Once you delete this org, there is no going back. Please
|
Once you delete this org, there is no going back.
|
||||||
be certain.
|
Please be certain.
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
<CardFooter className="flex justify-end gap-2">
|
<CardFooter className="flex justify-end gap-2">
|
||||||
|
@ -169,10 +169,11 @@ export default function GeneralPage() {
|
||||||
className="flex items-center gap-2"
|
className="flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
Delete
|
Delete Organization Data
|
||||||
</Button>
|
</Button>
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
</Card>
|
</Card>
|
||||||
|
</section>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { Metadata } from "next";
|
import { Metadata } from "next";
|
||||||
import { TopbarNav } from "./components/TopbarNav";
|
import { TopbarNav } from "@app/components/TopbarNav";
|
||||||
import { Cog, Combine, Link, Settings, Users, Waypoints } from "lucide-react";
|
import { Cog, Combine, Link, Settings, Users, Waypoints } from "lucide-react";
|
||||||
import Header from "./components/Header";
|
import { Header } from "@app/components/Header";
|
||||||
import { verifySession } from "@app/lib/auth/verifySession";
|
import { verifySession } from "@app/lib/auth/verifySession";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { internal } from "@app/api";
|
import { internal } from "@app/api";
|
||||||
|
@ -10,6 +10,7 @@ import { GetOrgResponse, ListOrgsResponse } from "@server/routers/org";
|
||||||
import { authCookieHeader } from "@app/api/cookies";
|
import { authCookieHeader } from "@app/api/cookies";
|
||||||
import { cache } from "react";
|
import { cache } from "react";
|
||||||
import { GetOrgUserResponse } from "@server/routers/user";
|
import { GetOrgUserResponse } from "@server/routers/user";
|
||||||
|
import UserProvider from "@app/providers/UserProvider";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
@ -99,38 +100,17 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
|
||||||
<div className="w-full border-b bg-neutral-100 dark:bg-neutral-800 select-none sm:px-0 px-3 fixed top-0 z-10">
|
<div className="w-full border-b bg-neutral-100 dark:bg-neutral-800 select-none sm:px-0 px-3 fixed top-0 z-10">
|
||||||
<div className="container mx-auto flex flex-col content-between">
|
<div className="container mx-auto flex flex-col content-between">
|
||||||
<div className="my-4">
|
<div className="my-4">
|
||||||
<Header
|
<UserProvider user={user}>
|
||||||
email={user.email}
|
<Header orgId={params.orgId} orgs={orgs} />
|
||||||
orgId={params.orgId}
|
</UserProvider>
|
||||||
orgs={orgs}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<TopbarNav items={topNavItems} orgId={params.orgId} />
|
<TopbarNav items={topNavItems} orgId={params.orgId} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="container mx-auto sm:px-0 px-3 pt-[165px]">{children}</div>
|
<div className="container mx-auto sm:px-0 px-3 pt-[165px]">
|
||||||
|
{children}
|
||||||
<footer className="w-full mt-6 py-3">
|
|
||||||
<div className="container mx-auto flex justify-end items-center px-3 sm:px-0 text-sm text-neutral-300 dark:text-neutral-700 space-x-3 select-none">
|
|
||||||
<div>Built by Fossorial</div>
|
|
||||||
<a
|
|
||||||
href="https://github.com/fosrl/pangolin"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
aria-label="GitHub"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="currentColor"
|
|
||||||
className="w-4 h-4"
|
|
||||||
>
|
|
||||||
<path d="M12 0C5.37 0 0 5.373 0 12c0 5.303 3.438 9.8 8.207 11.385.6.11.82-.26.82-.577v-2.17c-3.338.726-4.042-1.61-4.042-1.61-.546-1.385-1.333-1.755-1.333-1.755-1.09-.744.082-.73.082-.73 1.205.085 1.84 1.24 1.84 1.24 1.07 1.835 2.807 1.305 3.492.997.107-.775.42-1.305.763-1.605-2.665-.305-5.467-1.335-5.467-5.93 0-1.31.468-2.382 1.236-3.22-.123-.303-.535-1.523.117-3.176 0 0 1.008-.322 3.3 1.23a11.52 11.52 0 013.006-.403c1.02.005 2.045.137 3.006.403 2.29-1.552 3.295-1.23 3.295-1.23.654 1.653.242 2.873.12 3.176.77.838 1.235 1.91 1.235 3.22 0 4.605-2.805 5.623-5.475 5.92.43.37.814 1.1.814 2.22v3.293c0 .32.217.693.825.576C20.565 21.795 24 17.298 24 12 24 5.373 18.627 0 12 0z" />
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -412,7 +412,7 @@ export default function ResourceAuthenticationPage() {
|
||||||
onSubmit={usersRolesForm.handleSubmit(
|
onSubmit={usersRolesForm.handleSubmit(
|
||||||
onSubmitUsersRoles
|
onSubmitUsersRoles
|
||||||
)}
|
)}
|
||||||
className="space-y-8"
|
className="space-y-4"
|
||||||
>
|
>
|
||||||
<FormField
|
<FormField
|
||||||
control={usersRolesForm.control}
|
control={usersRolesForm.control}
|
||||||
|
@ -639,7 +639,7 @@ export default function ResourceAuthenticationPage() {
|
||||||
|
|
||||||
{whitelistEnabled && (
|
{whitelistEnabled && (
|
||||||
<Form {...whitelistForm}>
|
<Form {...whitelistForm}>
|
||||||
<form className="space-y-8">
|
<form className="space-y-4">
|
||||||
<FormField
|
<FormField
|
||||||
control={whitelistForm.control}
|
control={whitelistForm.control}
|
||||||
name="emails"
|
name="emails"
|
||||||
|
|
|
@ -439,7 +439,7 @@ export default function ReverseProxyTargets(props: {
|
||||||
onSubmit={addTargetForm.handleSubmit(
|
onSubmit={addTargetForm.handleSubmit(
|
||||||
addTarget as any,
|
addTarget as any,
|
||||||
)}
|
)}
|
||||||
className="space-y-8"
|
className="space-y-4"
|
||||||
>
|
>
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||||
<FormField
|
<FormField
|
||||||
|
|
|
@ -135,7 +135,7 @@ export default function GeneralForm() {
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
className="space-y-8"
|
className="space-y-4"
|
||||||
>
|
>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
|
|
|
@ -63,6 +63,7 @@ import { Checkbox } from "@app/components/ui/checkbox";
|
||||||
import { GenerateAccessTokenResponse } from "@server/routers/accessToken";
|
import { GenerateAccessTokenResponse } from "@server/routers/accessToken";
|
||||||
import { constructShareLink } from "@app/lib/shareLinks";
|
import { constructShareLink } from "@app/lib/shareLinks";
|
||||||
import { ShareLinkRow } from "./ShareLinksTable";
|
import { ShareLinkRow } from "./ShareLinksTable";
|
||||||
|
import { QRCodeSVG } from "qrcode.react";
|
||||||
|
|
||||||
type FormProps = {
|
type FormProps = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
@ -226,13 +227,13 @@ export default function CreateShareLinkForm({
|
||||||
>
|
>
|
||||||
<CredenzaContent>
|
<CredenzaContent>
|
||||||
<CredenzaHeader>
|
<CredenzaHeader>
|
||||||
<CredenzaTitle>Create Sharable Link</CredenzaTitle>
|
<CredenzaTitle>Create Shareable Link</CredenzaTitle>
|
||||||
<CredenzaDescription>
|
<CredenzaDescription>
|
||||||
Anyone with this link can access the resource
|
Anyone with this link can access the resource
|
||||||
</CredenzaDescription>
|
</CredenzaDescription>
|
||||||
</CredenzaHeader>
|
</CredenzaHeader>
|
||||||
<CredenzaBody>
|
<CredenzaBody>
|
||||||
<div className="space-y-8">
|
<div className="space-y-4">
|
||||||
{!link && (
|
{!link && (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form
|
||||||
|
@ -436,10 +437,10 @@ export default function CreateShareLinkForm({
|
||||||
Expiration time is how long the
|
Expiration time is how long the
|
||||||
link will be usable and provide
|
link will be usable and provide
|
||||||
access to the resource. After
|
access to the resource. After
|
||||||
this time, the link will expire
|
this time, the link will no
|
||||||
and no longer work, and users
|
longer work, and users who used
|
||||||
who used this link will lose
|
this link will lose access to
|
||||||
access to the resource.
|
the resource.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
@ -448,15 +449,25 @@ export default function CreateShareLinkForm({
|
||||||
{link && (
|
{link && (
|
||||||
<div className="max-w-md space-y-4">
|
<div className="max-w-md space-y-4">
|
||||||
<p>
|
<p>
|
||||||
You will be able to see this link once.
|
You will only be able to see this link once.
|
||||||
Make sure to copy it.
|
Make sure to copy it.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Anyone with this link can access the
|
Anyone with this link can access the
|
||||||
resource. Share it with care.
|
resource. Share it with care.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<div className="w-64 h-64 mx-auto flex items-center justify-center">
|
||||||
|
<QRCodeSVG
|
||||||
|
value={link}
|
||||||
|
size={256}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mx-auto">
|
||||||
<CopyTextBox text={link} wrapText={false} />
|
<CopyTextBox text={link} wrapText={false} />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CredenzaBody>
|
</CredenzaBody>
|
||||||
|
|
|
@ -77,7 +77,7 @@ export default function GeneralPage() {
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
className="space-y-8"
|
className="space-y-4"
|
||||||
>
|
>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
|
|
|
@ -203,7 +203,7 @@ PersistentKeepalive = 5`
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
className="space-y-8"
|
className="space-y-4"
|
||||||
id="create-site-form"
|
id="create-site-form"
|
||||||
>
|
>
|
||||||
<FormField
|
<FormField
|
||||||
|
|
|
@ -195,14 +195,14 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
|
||||||
if (originalRow.online) {
|
if (originalRow.online) {
|
||||||
return (
|
return (
|
||||||
<span className="text-green-500 flex items-center space-x-2">
|
<span className="text-green-500 flex items-center space-x-2">
|
||||||
<Check className="w-4 h-4" />
|
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
|
||||||
<span>Online</span>
|
<span>Online</span>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
return (
|
return (
|
||||||
<span className="text-red-500 flex items-center space-x-2">
|
<span className="text-gray-500 flex items-center space-x-2">
|
||||||
<X className="w-4 h-4" />
|
<div className="w-2 h-2 bg-gray-500 rounded-full"></div>
|
||||||
<span>Offline</span>
|
<span>Offline</span>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|
472
src/app/auth/reset-password/ResetPasswordForm.tsx
Normal file
472
src/app/auth/reset-password/ResetPasswordForm.tsx
Normal file
|
@ -0,0 +1,472 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
InputOTP,
|
||||||
|
InputOTPGroup,
|
||||||
|
InputOTPSeparator,
|
||||||
|
InputOTPSlot
|
||||||
|
} from "@/components/ui/input-otp";
|
||||||
|
import { AxiosResponse } from "axios";
|
||||||
|
import {
|
||||||
|
RequestPasswordResetBody,
|
||||||
|
RequestPasswordResetResponse,
|
||||||
|
resetPasswordBody,
|
||||||
|
ResetPasswordBody,
|
||||||
|
ResetPasswordResponse
|
||||||
|
} from "@server/routers/auth";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { Alert, AlertDescription } from "../../../components/ui/alert";
|
||||||
|
import { useToast } from "@app/hooks/useToast";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { formatAxiosError } from "@app/lib/utils";
|
||||||
|
import { createApiClient } from "@app/api";
|
||||||
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
|
import { passwordSchema } from "@server/auth/passwordSchema";
|
||||||
|
import { get } from "http";
|
||||||
|
import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp";
|
||||||
|
|
||||||
|
const requestSchema = z.object({
|
||||||
|
email: z.string().email()
|
||||||
|
});
|
||||||
|
|
||||||
|
const formSchema = z
|
||||||
|
.object({
|
||||||
|
email: z.string().email({ message: "Invalid email address" }),
|
||||||
|
token: z.string().min(8, { message: "Invalid token" }),
|
||||||
|
password: passwordSchema,
|
||||||
|
confirmPassword: passwordSchema
|
||||||
|
})
|
||||||
|
.refine((data) => data.password === data.confirmPassword, {
|
||||||
|
path: ["confirmPassword"],
|
||||||
|
message: "Passwords do not match"
|
||||||
|
});
|
||||||
|
|
||||||
|
const mfaSchema = z.object({
|
||||||
|
code: z.string().length(6, { message: "Invalid code" })
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ResetPasswordFormProps = {
|
||||||
|
emailParam?: string;
|
||||||
|
tokenParam?: string;
|
||||||
|
redirect?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ResetPasswordForm({
|
||||||
|
emailParam,
|
||||||
|
tokenParam,
|
||||||
|
redirect
|
||||||
|
}: ResetPasswordFormProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
function getState() {
|
||||||
|
if (emailParam && !tokenParam) {
|
||||||
|
return "request";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (emailParam && tokenParam) {
|
||||||
|
return "reset";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "request";
|
||||||
|
}
|
||||||
|
|
||||||
|
const [state, setState] = useState<"request" | "reset" | "mfa">(getState());
|
||||||
|
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const api = createApiClient(useEnvContext());
|
||||||
|
|
||||||
|
const form = useForm<z.infer<typeof formSchema>>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues: {
|
||||||
|
email: emailParam || "",
|
||||||
|
token: tokenParam || "",
|
||||||
|
password: "",
|
||||||
|
confirmPassword: ""
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const mfaForm = useForm<z.infer<typeof mfaSchema>>({
|
||||||
|
resolver: zodResolver(mfaSchema),
|
||||||
|
defaultValues: {
|
||||||
|
code: ""
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const requestForm = useForm<z.infer<typeof requestSchema>>({
|
||||||
|
resolver: zodResolver(requestSchema),
|
||||||
|
defaultValues: {
|
||||||
|
email: emailParam || ""
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function onRequest(data: z.infer<typeof requestSchema>) {
|
||||||
|
const { email } = data;
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
|
||||||
|
const res = await api
|
||||||
|
.post<AxiosResponse<RequestPasswordResetResponse>>(
|
||||||
|
"/auth/reset-password/request",
|
||||||
|
{
|
||||||
|
email
|
||||||
|
} as RequestPasswordResetBody
|
||||||
|
)
|
||||||
|
.catch((e) => {
|
||||||
|
setError(formatAxiosError(e, "An error occurred"));
|
||||||
|
console.error("Failed to request reset:", e);
|
||||||
|
setIsSubmitting(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res && res.data?.data) {
|
||||||
|
setError(null);
|
||||||
|
setState("reset");
|
||||||
|
setIsSubmitting(false);
|
||||||
|
form.setValue("email", email);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onReset(data: any) {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
|
||||||
|
const { password, email, token } = form.getValues();
|
||||||
|
const { code } = mfaForm.getValues();
|
||||||
|
|
||||||
|
const res = await api
|
||||||
|
.post<AxiosResponse<ResetPasswordResponse>>(
|
||||||
|
"/auth/reset-password",
|
||||||
|
{
|
||||||
|
email,
|
||||||
|
token,
|
||||||
|
newPassword: password,
|
||||||
|
code
|
||||||
|
} as ResetPasswordBody
|
||||||
|
)
|
||||||
|
.catch((e) => {
|
||||||
|
setError(formatAxiosError(e, "An error occurred"));
|
||||||
|
console.error("Failed to reset password:", e);
|
||||||
|
setIsSubmitting(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(res);
|
||||||
|
|
||||||
|
if (res) {
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
if (res.data.data?.codeRequested) {
|
||||||
|
setState("mfa");
|
||||||
|
setIsSubmitting(false);
|
||||||
|
mfaForm.reset();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSuccessMessage("Password reset successfully! Back to login...");
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (redirect && redirect.includes("http")) {
|
||||||
|
window.location.href = redirect;
|
||||||
|
}
|
||||||
|
if (redirect) {
|
||||||
|
router.push(redirect);
|
||||||
|
} else {
|
||||||
|
router.push("/login");
|
||||||
|
}
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}, 1500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Reset Password</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Follow the steps to reset your password
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{state === "request" && (
|
||||||
|
<Form {...requestForm}>
|
||||||
|
<form
|
||||||
|
onSubmit={requestForm.handleSubmit(
|
||||||
|
onRequest
|
||||||
|
)}
|
||||||
|
className="space-y-4"
|
||||||
|
id="form"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={requestForm.control}
|
||||||
|
name="email"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Email</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="Enter your email"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
We'll send a password reset
|
||||||
|
code to this email address.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state === "reset" && (
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
onSubmit={form.handleSubmit(onReset)}
|
||||||
|
className="space-y-4"
|
||||||
|
id="form"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="email"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Email</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="Email"
|
||||||
|
{...field}
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!tokenParam && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="token"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
Reset Code
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="Enter reset code sent to your email"
|
||||||
|
type="password"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="password"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
New Password
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
placeholder="Password"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="confirmPassword"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
Confirm New Password
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
placeholder="Confirm Password"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state === "mfa" && (
|
||||||
|
<Form {...mfaForm}>
|
||||||
|
<form
|
||||||
|
onSubmit={mfaForm.handleSubmit(onReset)}
|
||||||
|
className="space-y-4"
|
||||||
|
id="form"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={mfaForm.control}
|
||||||
|
name="code"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
Authenticator Code
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<InputOTP
|
||||||
|
maxLength={6}
|
||||||
|
{...field}
|
||||||
|
pattern={REGEXP_ONLY_DIGITS_AND_CHARS}
|
||||||
|
>
|
||||||
|
<InputOTPGroup>
|
||||||
|
<InputOTPSlot
|
||||||
|
index={0}
|
||||||
|
/>
|
||||||
|
<InputOTPSlot
|
||||||
|
index={1}
|
||||||
|
/>
|
||||||
|
<InputOTPSlot
|
||||||
|
index={2}
|
||||||
|
/>
|
||||||
|
</InputOTPGroup>
|
||||||
|
<InputOTPSeparator />
|
||||||
|
<InputOTPGroup>
|
||||||
|
<InputOTPSlot
|
||||||
|
index={3}
|
||||||
|
/>
|
||||||
|
<InputOTPSlot
|
||||||
|
index={4}
|
||||||
|
/>
|
||||||
|
<InputOTPSlot
|
||||||
|
index={5}
|
||||||
|
/>
|
||||||
|
</InputOTPGroup>
|
||||||
|
</InputOTP>
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{successMessage && (
|
||||||
|
<Alert variant="success">
|
||||||
|
<AlertDescription>
|
||||||
|
{successMessage}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{(state === "reset" || state === "mfa") && (
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
form="form"
|
||||||
|
className="w-full"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
{isSubmitting && (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
)}
|
||||||
|
{state === "reset"
|
||||||
|
? "Reset Password"
|
||||||
|
: "Submit Code"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state === "request" && (
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
form="form"
|
||||||
|
className="w-full"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
{isSubmitting && (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
)}
|
||||||
|
Request Reset
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state === "mfa" && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="w-full"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setState("reset");
|
||||||
|
mfaForm.reset();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Back to Password
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(state === "mfa" || state === "reset") && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="w-full"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setState("request");
|
||||||
|
form.reset();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Back to Email
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
46
src/app/auth/reset-password/page.tsx
Normal file
46
src/app/auth/reset-password/page.tsx
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
import { verifySession } from "@app/lib/auth/verifySession";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { cache } from "react";
|
||||||
|
import ResetPasswordForm from "./ResetPasswordForm";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function Page(props: {
|
||||||
|
searchParams: Promise<{
|
||||||
|
redirect: string | undefined;
|
||||||
|
email: string | undefined;
|
||||||
|
token: string | undefined;
|
||||||
|
}>;
|
||||||
|
}) {
|
||||||
|
const searchParams = await props.searchParams;
|
||||||
|
const getUser = cache(verifySession);
|
||||||
|
const user = await getUser();
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
redirect("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ResetPasswordForm
|
||||||
|
redirect={searchParams.redirect}
|
||||||
|
tokenParam={searchParams.token}
|
||||||
|
emailParam={searchParams.email}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<p className="text-center text-muted-foreground mt-4">
|
||||||
|
<Link
|
||||||
|
href={
|
||||||
|
!searchParams.redirect
|
||||||
|
? `/auth/signup`
|
||||||
|
: `/auth/signup?redirect=${searchParams.redirect}`
|
||||||
|
}
|
||||||
|
className="underline"
|
||||||
|
>
|
||||||
|
Go to login
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -114,7 +114,7 @@ export default function SignupForm({ redirect }: SignupFormProps) {
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
className="space-y-8"
|
className="space-y-4"
|
||||||
>
|
>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
|
|
|
@ -138,7 +138,7 @@ export default function VerifyEmailForm({
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
className="space-y-8"
|
className="space-y-4"
|
||||||
>
|
>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
|
|
|
@ -4,6 +4,7 @@ import { Figtree } from "next/font/google";
|
||||||
import { Toaster } from "@/components/ui/toaster";
|
import { Toaster } from "@/components/ui/toaster";
|
||||||
import { ThemeProvider } from "@app/providers/ThemeProvider";
|
import { ThemeProvider } from "@app/providers/ThemeProvider";
|
||||||
import EnvProvider from "@app/providers/EnvProvider";
|
import EnvProvider from "@app/providers/EnvProvider";
|
||||||
|
import { Separator } from "@app/components/ui/separator";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: `Dashboard - Pangolin`,
|
title: `Dashboard - Pangolin`,
|
||||||
|
@ -17,6 +18,8 @@ export default async function RootLayout({
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
|
const version = process.env.APP_VERSION;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html suppressHydrationWarning>
|
<html suppressHydrationWarning>
|
||||||
<body className={`${font.className}`}>
|
<body className={`${font.className}`}>
|
||||||
|
@ -38,6 +41,37 @@ export default async function RootLayout({
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|
||||||
|
<footer className="w-full mt-6 py-3">
|
||||||
|
<div className="container mx-auto flex justify-center items-center h-5 space-x-4 text-sm text-neutral-400 select-none">
|
||||||
|
<div>Built by Fossorial</div>
|
||||||
|
<Separator orientation="vertical" />
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div>Open Source</div>
|
||||||
|
<a
|
||||||
|
href="https://github.com/fosrl/pangolin"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
aria-label="GitHub"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
className="w-4 h-4"
|
||||||
|
>
|
||||||
|
<path d="M12 0C5.37 0 0 5.373 0 12c0 5.303 3.438 9.8 8.207 11.385.6.11.82-.26.82-.577v-2.17c-3.338.726-4.042-1.61-4.042-1.61-.546-1.385-1.333-1.755-1.333-1.755-1.09-.744.082-.73.082-.73 1.205.085 1.84 1.24 1.84 1.24 1.07 1.835 2.807 1.305 3.492.997.107-.775.42-1.305.763-1.605-2.665-.305-5.467-1.335-5.467-5.93 0-1.31.468-2.382 1.236-3.22-.123-.303-.535-1.523.117-3.176 0 0 1.008-.322 3.3 1.23a11.52 11.52 0 013.006-.403c1.02.005 2.045.137 3.006.403 2.29-1.552 3.295-1.23 3.295-1.23.654 1.653.242 2.873.12 3.176.77.838 1.235 1.91 1.235 3.22 0 4.605-2.805 5.623-5.475 5.92.43.37.814 1.1.814 2.22v3.293c0 .32.217.693.825.576C20.565 21.795 24 17.298 24 12 24 5.373 18.627 0 12 0z" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{version && (
|
||||||
|
<>
|
||||||
|
<Separator orientation="vertical" />
|
||||||
|
<div>v{version}</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
</EnvProvider>
|
</EnvProvider>
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
|
|
@ -1,176 +0,0 @@
|
||||||
"use client"
|
|
||||||
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod"
|
|
||||||
import { CalendarIcon, CaretSortIcon, CheckIcon } from "@radix-ui/react-icons"
|
|
||||||
import { useForm } from "react-hook-form"
|
|
||||||
import { z } from "zod"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
import { toast } from "@/hooks/useToast"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import {
|
|
||||||
Command,
|
|
||||||
CommandEmpty,
|
|
||||||
CommandGroup,
|
|
||||||
CommandInput,
|
|
||||||
CommandItem,
|
|
||||||
CommandList,
|
|
||||||
} from "@/components/ui/command"
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormDescription,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from "@/components/ui/form"
|
|
||||||
import { Input } from "@/components/ui/input"
|
|
||||||
import {
|
|
||||||
Popover,
|
|
||||||
PopoverContent,
|
|
||||||
PopoverTrigger,
|
|
||||||
} from "@/components/ui/popover"
|
|
||||||
|
|
||||||
const languages = [
|
|
||||||
{ label: "English", value: "en" },
|
|
||||||
{ label: "French", value: "fr" },
|
|
||||||
{ label: "German", value: "de" },
|
|
||||||
{ label: "Spanish", value: "es" },
|
|
||||||
{ label: "Portuguese", value: "pt" },
|
|
||||||
{ label: "Russian", value: "ru" },
|
|
||||||
{ label: "Japanese", value: "ja" },
|
|
||||||
{ label: "Korean", value: "ko" },
|
|
||||||
{ label: "Chinese", value: "zh" },
|
|
||||||
] as const
|
|
||||||
|
|
||||||
const accountFormSchema = z.object({
|
|
||||||
name: z
|
|
||||||
.string()
|
|
||||||
.min(2, {
|
|
||||||
message: "Name must be at least 2 characters.",
|
|
||||||
})
|
|
||||||
.max(30, {
|
|
||||||
message: "Name must not be longer than 30 characters.",
|
|
||||||
}),
|
|
||||||
dob: z.date({
|
|
||||||
required_error: "A date of birth is required.",
|
|
||||||
}),
|
|
||||||
language: z.string({
|
|
||||||
required_error: "Please select a language.",
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
|
|
||||||
type AccountFormValues = z.infer<typeof accountFormSchema>
|
|
||||||
|
|
||||||
// This can come from your database or API.
|
|
||||||
const defaultValues: Partial<AccountFormValues> = {
|
|
||||||
// name: "Your name",
|
|
||||||
// dob: new Date("2023-01-23"),
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AccountForm() {
|
|
||||||
const form = useForm<AccountFormValues>({
|
|
||||||
resolver: zodResolver(accountFormSchema),
|
|
||||||
defaultValues,
|
|
||||||
})
|
|
||||||
|
|
||||||
function onSubmit(data: AccountFormValues) {
|
|
||||||
toast({
|
|
||||||
title: "You submitted the following values:",
|
|
||||||
description: (
|
|
||||||
<pre className="mt-2 w-[340px] rounded-md bg-slate-950 p-4">
|
|
||||||
<code className="text-white">{JSON.stringify(data, null, 2)}</code>
|
|
||||||
</pre>
|
|
||||||
),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Form {...form}>
|
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="name"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Name</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder="Your name" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>
|
|
||||||
This is the name that will be displayed on your profile and in
|
|
||||||
emails.
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="language"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="flex flex-col">
|
|
||||||
<FormLabel>Language</FormLabel>
|
|
||||||
<Popover>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<FormControl>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
role="combobox"
|
|
||||||
className={cn(
|
|
||||||
"w-[200px] justify-between",
|
|
||||||
!field.value && "text-muted-foreground"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{field.value
|
|
||||||
? languages.find(
|
|
||||||
(language) => language.value === field.value
|
|
||||||
)?.label
|
|
||||||
: "Select language"}
|
|
||||||
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
|
||||||
</Button>
|
|
||||||
</FormControl>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="w-[200px] p-0">
|
|
||||||
<Command>
|
|
||||||
<CommandInput placeholder="Search language..." />
|
|
||||||
<CommandList>
|
|
||||||
<CommandEmpty>No language found.</CommandEmpty>
|
|
||||||
<CommandGroup>
|
|
||||||
{languages.map((language) => (
|
|
||||||
<CommandItem
|
|
||||||
value={language.label}
|
|
||||||
key={language.value}
|
|
||||||
onSelect={() => {
|
|
||||||
form.setValue("language", language.value)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CheckIcon
|
|
||||||
className={cn(
|
|
||||||
"mr-2 h-4 w-4",
|
|
||||||
language.value === field.value
|
|
||||||
? "opacity-100"
|
|
||||||
: "opacity-0"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
{language.label}
|
|
||||||
</CommandItem>
|
|
||||||
))}
|
|
||||||
</CommandGroup>
|
|
||||||
</CommandList>
|
|
||||||
</Command>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
<FormDescription>
|
|
||||||
This is the language that will be used in the dashboard.
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<Button type="submit">Update account</Button>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,18 +0,0 @@
|
||||||
import { Separator } from "@/components/ui/separator"
|
|
||||||
import { AccountForm } from "./account-form"
|
|
||||||
|
|
||||||
export default function SettingsAccountPage() {
|
|
||||||
return (
|
|
||||||
<div className="space-y-8">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-medium">Account</h3>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Update your account settings. Set your preferred language and
|
|
||||||
timezone.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Separator />
|
|
||||||
<AccountForm />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,164 +0,0 @@
|
||||||
"use client"
|
|
||||||
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod"
|
|
||||||
import { ChevronDownIcon } from "@radix-ui/react-icons"
|
|
||||||
import { useForm } from "react-hook-form"
|
|
||||||
import { z } from "zod"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
import { toast } from "@/hooks/useToast"
|
|
||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormDescription,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from "@/components/ui/form"
|
|
||||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
|
|
||||||
|
|
||||||
const appearanceFormSchema = z.object({
|
|
||||||
theme: z.enum(["light", "dark"], {
|
|
||||||
required_error: "Please select a theme.",
|
|
||||||
}),
|
|
||||||
font: z.enum(["inter", "manrope", "system"], {
|
|
||||||
invalid_type_error: "Select a font",
|
|
||||||
required_error: "Please select a font.",
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
|
|
||||||
type AppearanceFormValues = z.infer<typeof appearanceFormSchema>
|
|
||||||
|
|
||||||
// This can come from your database or API.
|
|
||||||
const defaultValues: Partial<AppearanceFormValues> = {
|
|
||||||
theme: "light",
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AppearanceForm() {
|
|
||||||
const form = useForm<AppearanceFormValues>({
|
|
||||||
resolver: zodResolver(appearanceFormSchema),
|
|
||||||
defaultValues,
|
|
||||||
})
|
|
||||||
|
|
||||||
function onSubmit(data: AppearanceFormValues) {
|
|
||||||
toast({
|
|
||||||
title: "You submitted the following values:",
|
|
||||||
description: (
|
|
||||||
<pre className="mt-2 w-[340px] rounded-md bg-slate-950 p-4">
|
|
||||||
<code className="text-white">{JSON.stringify(data, null, 2)}</code>
|
|
||||||
</pre>
|
|
||||||
),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Form {...form}>
|
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="font"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Font</FormLabel>
|
|
||||||
<div className="relative w-max">
|
|
||||||
<FormControl>
|
|
||||||
<select
|
|
||||||
className={cn(
|
|
||||||
buttonVariants({ variant: "outline" }),
|
|
||||||
"w-[200px] appearance-none font-normal"
|
|
||||||
)}
|
|
||||||
{...field}
|
|
||||||
>
|
|
||||||
<option value="inter">Inter</option>
|
|
||||||
<option value="manrope">Manrope</option>
|
|
||||||
<option value="system">System</option>
|
|
||||||
</select>
|
|
||||||
</FormControl>
|
|
||||||
<ChevronDownIcon className="absolute right-3 top-2.5 h-4 w-4 opacity-50" />
|
|
||||||
</div>
|
|
||||||
<FormDescription>
|
|
||||||
Set the font you want to use in the dashboard.
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="theme"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="space-y-1">
|
|
||||||
<FormLabel>Theme</FormLabel>
|
|
||||||
<FormDescription>
|
|
||||||
Select the theme for the dashboard.
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
<RadioGroup
|
|
||||||
onValueChange={field.onChange}
|
|
||||||
defaultValue={field.value}
|
|
||||||
className="grid max-w-md grid-cols-2 gap-8 pt-2"
|
|
||||||
>
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel className="[&:has([data-state=checked])>div]:border-primary">
|
|
||||||
<FormControl>
|
|
||||||
<RadioGroupItem value="light" className="sr-only" />
|
|
||||||
</FormControl>
|
|
||||||
<div className="items-center rounded-md border-2 border-muted p-1 hover:border-accent">
|
|
||||||
<div className="space-y-2 rounded-sm bg-[#ecedef] p-2">
|
|
||||||
<div className="space-y-2 rounded-md bg-white p-2 shadow-sm">
|
|
||||||
<div className="h-2 w-[80px] rounded-lg bg-[#ecedef]" />
|
|
||||||
<div className="h-2 w-[100px] rounded-lg bg-[#ecedef]" />
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-2 rounded-md bg-white p-2 shadow-sm">
|
|
||||||
<div className="h-4 w-4 rounded-full bg-[#ecedef]" />
|
|
||||||
<div className="h-2 w-[100px] rounded-lg bg-[#ecedef]" />
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-2 rounded-md bg-white p-2 shadow-sm">
|
|
||||||
<div className="h-4 w-4 rounded-full bg-[#ecedef]" />
|
|
||||||
<div className="h-2 w-[100px] rounded-lg bg-[#ecedef]" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span className="block w-full p-2 text-center font-normal">
|
|
||||||
Light
|
|
||||||
</span>
|
|
||||||
</FormLabel>
|
|
||||||
</FormItem>
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel className="[&:has([data-state=checked])>div]:border-primary">
|
|
||||||
<FormControl>
|
|
||||||
<RadioGroupItem value="dark" className="sr-only" />
|
|
||||||
</FormControl>
|
|
||||||
<div className="items-center rounded-md border-2 border-muted bg-popover p-1 hover:bg-accent hover:text-accent-foreground">
|
|
||||||
<div className="space-y-2 rounded-sm bg-slate-950 p-2">
|
|
||||||
<div className="space-y-2 rounded-md bg-slate-800 p-2 shadow-sm">
|
|
||||||
<div className="h-2 w-[80px] rounded-lg bg-slate-400" />
|
|
||||||
<div className="h-2 w-[100px] rounded-lg bg-slate-400" />
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-2 rounded-md bg-slate-800 p-2 shadow-sm">
|
|
||||||
<div className="h-4 w-4 rounded-full bg-slate-400" />
|
|
||||||
<div className="h-2 w-[100px] rounded-lg bg-slate-400" />
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-2 rounded-md bg-slate-800 p-2 shadow-sm">
|
|
||||||
<div className="h-4 w-4 rounded-full bg-slate-400" />
|
|
||||||
<div className="h-2 w-[100px] rounded-lg bg-slate-400" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span className="block w-full p-2 text-center font-normal">
|
|
||||||
Dark
|
|
||||||
</span>
|
|
||||||
</FormLabel>
|
|
||||||
</FormItem>
|
|
||||||
</RadioGroup>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Button type="submit">Update preferences</Button>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,18 +0,0 @@
|
||||||
import { Separator } from "@/components/ui/separator"
|
|
||||||
import { AppearanceForm } from "./appearance-form"
|
|
||||||
|
|
||||||
export default function SettingsAppearancePage() {
|
|
||||||
return (
|
|
||||||
<div className="space-y-8">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-medium">Appearance</h3>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Customize the appearance of the app. Automatically switch between day
|
|
||||||
and night themes.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Separator />
|
|
||||||
<AppearanceForm />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,132 +0,0 @@
|
||||||
"use client"
|
|
||||||
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod"
|
|
||||||
import { useForm } from "react-hook-form"
|
|
||||||
import { z } from "zod"
|
|
||||||
|
|
||||||
import { toast } from "@/hooks/useToast"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { Checkbox } from "@/components/ui/checkbox"
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormDescription,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from "@/components/ui/form"
|
|
||||||
|
|
||||||
const items = [
|
|
||||||
{
|
|
||||||
id: "recents",
|
|
||||||
label: "Recents",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "home",
|
|
||||||
label: "Home",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "applications",
|
|
||||||
label: "Applications",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "desktop",
|
|
||||||
label: "Desktop",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "downloads",
|
|
||||||
label: "Downloads",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "documents",
|
|
||||||
label: "Documents",
|
|
||||||
},
|
|
||||||
] as const
|
|
||||||
|
|
||||||
const displayFormSchema = z.object({
|
|
||||||
items: z.array(z.string()).refine((value) => value.some((item) => item), {
|
|
||||||
message: "You have to select at least one item.",
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
|
|
||||||
type DisplayFormValues = z.infer<typeof displayFormSchema>
|
|
||||||
|
|
||||||
// This can come from your database or API.
|
|
||||||
const defaultValues: Partial<DisplayFormValues> = {
|
|
||||||
items: ["recents", "home"],
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DisplayForm() {
|
|
||||||
const form = useForm<DisplayFormValues>({
|
|
||||||
resolver: zodResolver(displayFormSchema),
|
|
||||||
defaultValues,
|
|
||||||
})
|
|
||||||
|
|
||||||
function onSubmit(data: DisplayFormValues) {
|
|
||||||
toast({
|
|
||||||
title: "You submitted the following values:",
|
|
||||||
description: (
|
|
||||||
<pre className="mt-2 w-[340px] rounded-md bg-slate-950 p-4">
|
|
||||||
<code className="text-white">{JSON.stringify(data, null, 2)}</code>
|
|
||||||
</pre>
|
|
||||||
),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Form {...form}>
|
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="items"
|
|
||||||
render={() => (
|
|
||||||
<FormItem>
|
|
||||||
<div className="mb-4">
|
|
||||||
<FormLabel className="text-base">Sidebar</FormLabel>
|
|
||||||
<FormDescription>
|
|
||||||
Select the items you want to display in the sidebar.
|
|
||||||
</FormDescription>
|
|
||||||
</div>
|
|
||||||
{items.map((item) => (
|
|
||||||
<FormField
|
|
||||||
key={item.id}
|
|
||||||
control={form.control}
|
|
||||||
name="items"
|
|
||||||
render={({ field }) => {
|
|
||||||
return (
|
|
||||||
<FormItem
|
|
||||||
key={item.id}
|
|
||||||
className="flex flex-row items-start space-x-3 space-y-0"
|
|
||||||
>
|
|
||||||
<FormControl>
|
|
||||||
<Checkbox
|
|
||||||
checked={field.value?.includes(item.id)}
|
|
||||||
onCheckedChange={(checked) => {
|
|
||||||
return checked
|
|
||||||
? field.onChange([...field.value, item.id])
|
|
||||||
: field.onChange(
|
|
||||||
field.value?.filter(
|
|
||||||
(value) => value !== item.id
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormLabel className="font-normal">
|
|
||||||
{item.label}
|
|
||||||
</FormLabel>
|
|
||||||
</FormItem>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<Button type="submit">Update display</Button>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,17 +0,0 @@
|
||||||
import { Separator } from "@/components/ui/separator"
|
|
||||||
import { DisplayForm } from "./display-form"
|
|
||||||
|
|
||||||
export default function SettingsDisplayPage() {
|
|
||||||
return (
|
|
||||||
<div className="space-y-8">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-medium">Display</h3>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Turn items on or off to control what's displayed in the app.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Separator />
|
|
||||||
<DisplayForm />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
36
src/app/profile/general/layout_.tsx
Normal file
36
src/app/profile/general/layout_.tsx
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||||
|
import { SidebarSettings } from "@app/components/SidebarSettings";
|
||||||
|
import { verifySession } from "@app/lib/auth/verifySession";
|
||||||
|
import UserProvider from "@app/providers/UserProvider";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { cache } from "react";
|
||||||
|
|
||||||
|
type ProfileGeneralProps = {
|
||||||
|
children: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function GeneralSettingsPage({
|
||||||
|
children
|
||||||
|
}: ProfileGeneralProps) {
|
||||||
|
const getUser = cache(verifySession);
|
||||||
|
const user = await getUser();
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
redirect(`/?redirect=/profile/general`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sidebarNavItems = [
|
||||||
|
{
|
||||||
|
title: "Authentication",
|
||||||
|
href: `/{orgId}/settings/general`
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<UserProvider user={user}>
|
||||||
|
{children}
|
||||||
|
</UserProvider>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
14
src/app/profile/general/page_.tsx
Normal file
14
src/app/profile/general/page_.tsx
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import Enable2FaForm from "./components/Enable2FaForm";
|
||||||
|
|
||||||
|
export default function ProfileGeneralPage() {
|
||||||
|
const [open, setOpen] = useState(true);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Enable2FaForm open={open} setOpen={setOpen} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,76 +0,0 @@
|
||||||
import { Metadata } from "next"
|
|
||||||
import Image from "next/image"
|
|
||||||
|
|
||||||
import { Separator } from "@/components/ui/separator"
|
|
||||||
import { SidebarNav } from "@/components/sidebar-nav"
|
|
||||||
import Header from "../[orgId]/settings/components/Header"
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: "Forms",
|
|
||||||
description: "Advanced form example using react-hook-form and Zod.",
|
|
||||||
}
|
|
||||||
|
|
||||||
const sidebarNavItems = [
|
|
||||||
{
|
|
||||||
title: "Profile",
|
|
||||||
href: "/configuration",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Account",
|
|
||||||
href: "/configuration/account",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Appearance",
|
|
||||||
href: "/configuration/appearance",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Notifications",
|
|
||||||
href: "/configuration/notifications",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Display",
|
|
||||||
href: "/configuration/display",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
interface SettingsLayoutProps {
|
|
||||||
children: React.ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SettingsLayout({ children }: SettingsLayoutProps) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="md:hidden">
|
|
||||||
<Image
|
|
||||||
src="/configuration/forms-light.png"
|
|
||||||
width={1280}
|
|
||||||
height={791}
|
|
||||||
alt="Forms"
|
|
||||||
className="block dark:hidden"
|
|
||||||
/>
|
|
||||||
<Image
|
|
||||||
src="/configuration/forms-dark.png"
|
|
||||||
width={1280}
|
|
||||||
height={791}
|
|
||||||
alt="Forms"
|
|
||||||
className="hidden dark:block"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="hidden space-y-8 p-10 pb-16 md:block">
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<h2 className="text-2xl font-bold tracking-tight">Settings</h2>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
Manage your account settings and set e-mail preferences.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Separator className="my-6" />
|
|
||||||
<div className="flex flex-col space-y-4 lg:flex-row lg:space-x-12 lg:space-y-0">
|
|
||||||
<aside className="-mx-4 lg:w-1/5">
|
|
||||||
<SidebarNav items={sidebarNavItems} />
|
|
||||||
</aside>
|
|
||||||
<div className="flex-1 lg:max-w-2xl">{children}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
74
src/app/profile/layout_.tsx
Normal file
74
src/app/profile/layout_.tsx
Normal file
|
@ -0,0 +1,74 @@
|
||||||
|
import { Metadata } from "next";
|
||||||
|
import { verifySession } from "@app/lib/auth/verifySession";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { cache } from "react";
|
||||||
|
import Header from "@app/components/Header";
|
||||||
|
import { internal } from "@app/api";
|
||||||
|
import { AxiosResponse } from "axios";
|
||||||
|
import { ListOrgsResponse } from "@server/routers/org";
|
||||||
|
import { authCookieHeader } from "@app/api/cookies";
|
||||||
|
import { TopbarNav } from "@app/components/TopbarNav";
|
||||||
|
import { Settings } from "lucide-react";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: `User Settings - Pangolin`,
|
||||||
|
description: ""
|
||||||
|
};
|
||||||
|
|
||||||
|
const topNavItems = [
|
||||||
|
{
|
||||||
|
title: "User Settings",
|
||||||
|
href: "/profile/general",
|
||||||
|
icon: <Settings className="h-4 w-4" />
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
interface SettingsLayoutProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
params: Promise<{}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function SettingsLayout(props: SettingsLayoutProps) {
|
||||||
|
const { children } = props;
|
||||||
|
|
||||||
|
const getUser = cache(verifySession);
|
||||||
|
const user = await getUser();
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
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) {
|
||||||
|
console.error("Error fetching orgs", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="w-full border-b bg-neutral-100 dark:bg-neutral-800 select-none sm:px-0 px-3 fixed top-0 z-10">
|
||||||
|
<div className="container mx-auto flex flex-col content-between">
|
||||||
|
<div className="my-4">
|
||||||
|
<Header email={user.email} orgs={orgs} />
|
||||||
|
</div>
|
||||||
|
<TopbarNav items={topNavItems} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="container mx-auto sm:px-0 px-3 pt-[165px]">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,222 +0,0 @@
|
||||||
"use client"
|
|
||||||
|
|
||||||
import Link from "next/link"
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod"
|
|
||||||
import { useForm } from "react-hook-form"
|
|
||||||
import { z } from "zod"
|
|
||||||
|
|
||||||
import { toast } from "@/hooks/useToast"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { Checkbox } from "@/components/ui/checkbox"
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormDescription,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from "@/components/ui/form"
|
|
||||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
|
|
||||||
import { Switch } from "@/components/ui/switch"
|
|
||||||
|
|
||||||
const notificationsFormSchema = z.object({
|
|
||||||
type: z.enum(["all", "mentions", "none"], {
|
|
||||||
required_error: "You need to select a notification type.",
|
|
||||||
}),
|
|
||||||
mobile: z.boolean().default(false).optional(),
|
|
||||||
communication_emails: z.boolean().default(false).optional(),
|
|
||||||
social_emails: z.boolean().default(false).optional(),
|
|
||||||
marketing_emails: z.boolean().default(false).optional(),
|
|
||||||
security_emails: z.boolean(),
|
|
||||||
})
|
|
||||||
|
|
||||||
type NotificationsFormValues = z.infer<typeof notificationsFormSchema>
|
|
||||||
|
|
||||||
// This can come from your database or API.
|
|
||||||
const defaultValues: Partial<NotificationsFormValues> = {
|
|
||||||
communication_emails: false,
|
|
||||||
marketing_emails: false,
|
|
||||||
social_emails: true,
|
|
||||||
security_emails: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
export function NotificationsForm() {
|
|
||||||
const form = useForm<NotificationsFormValues>({
|
|
||||||
resolver: zodResolver(notificationsFormSchema),
|
|
||||||
defaultValues,
|
|
||||||
})
|
|
||||||
|
|
||||||
function onSubmit(data: NotificationsFormValues) {
|
|
||||||
toast({
|
|
||||||
title: "You submitted the following values:",
|
|
||||||
description: (
|
|
||||||
<pre className="mt-2 w-[340px] rounded-md bg-slate-950 p-4">
|
|
||||||
<code className="text-white">{JSON.stringify(data, null, 2)}</code>
|
|
||||||
</pre>
|
|
||||||
),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Form {...form}>
|
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="type"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="space-y-3">
|
|
||||||
<FormLabel>Notify me about...</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<RadioGroup
|
|
||||||
onValueChange={field.onChange}
|
|
||||||
defaultValue={field.value}
|
|
||||||
className="flex flex-col space-y-1"
|
|
||||||
>
|
|
||||||
<FormItem className="flex items-center space-x-3 space-y-0">
|
|
||||||
<FormControl>
|
|
||||||
<RadioGroupItem value="all" />
|
|
||||||
</FormControl>
|
|
||||||
<FormLabel className="font-normal">
|
|
||||||
All new messages
|
|
||||||
</FormLabel>
|
|
||||||
</FormItem>
|
|
||||||
<FormItem className="flex items-center space-x-3 space-y-0">
|
|
||||||
<FormControl>
|
|
||||||
<RadioGroupItem value="mentions" />
|
|
||||||
</FormControl>
|
|
||||||
<FormLabel className="font-normal">
|
|
||||||
Direct messages and mentions
|
|
||||||
</FormLabel>
|
|
||||||
</FormItem>
|
|
||||||
<FormItem className="flex items-center space-x-3 space-y-0">
|
|
||||||
<FormControl>
|
|
||||||
<RadioGroupItem value="none" />
|
|
||||||
</FormControl>
|
|
||||||
<FormLabel className="font-normal">Nothing</FormLabel>
|
|
||||||
</FormItem>
|
|
||||||
</RadioGroup>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<div>
|
|
||||||
<h3 className="mb-4 text-lg font-medium">Email Notifications</h3>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="communication_emails"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<FormLabel className="text-base">
|
|
||||||
Communication emails
|
|
||||||
</FormLabel>
|
|
||||||
<FormDescription>
|
|
||||||
Receive emails about your account activity.
|
|
||||||
</FormDescription>
|
|
||||||
</div>
|
|
||||||
<FormControl>
|
|
||||||
<Switch
|
|
||||||
checked={field.value}
|
|
||||||
onCheckedChange={field.onChange}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="marketing_emails"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<FormLabel className="text-base">
|
|
||||||
Marketing emails
|
|
||||||
</FormLabel>
|
|
||||||
<FormDescription>
|
|
||||||
Receive emails about new products, features, and more.
|
|
||||||
</FormDescription>
|
|
||||||
</div>
|
|
||||||
<FormControl>
|
|
||||||
<Switch
|
|
||||||
checked={field.value}
|
|
||||||
onCheckedChange={field.onChange}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="social_emails"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<FormLabel className="text-base">Social emails</FormLabel>
|
|
||||||
<FormDescription>
|
|
||||||
Receive emails for friend requests, follows, and more.
|
|
||||||
</FormDescription>
|
|
||||||
</div>
|
|
||||||
<FormControl>
|
|
||||||
<Switch
|
|
||||||
checked={field.value}
|
|
||||||
onCheckedChange={field.onChange}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="security_emails"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<FormLabel className="text-base">Security emails</FormLabel>
|
|
||||||
<FormDescription>
|
|
||||||
Receive emails about your account activity and security.
|
|
||||||
</FormDescription>
|
|
||||||
</div>
|
|
||||||
<FormControl>
|
|
||||||
<Switch
|
|
||||||
checked={field.value}
|
|
||||||
onCheckedChange={field.onChange}
|
|
||||||
disabled
|
|
||||||
aria-readonly
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="mobile"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="flex flex-row items-start space-x-3 space-y-0">
|
|
||||||
<FormControl>
|
|
||||||
<Checkbox
|
|
||||||
checked={field.value}
|
|
||||||
onCheckedChange={field.onChange}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<div className="space-y-1 leading-none">
|
|
||||||
<FormLabel>
|
|
||||||
Use different settings for my mobile devices
|
|
||||||
</FormLabel>
|
|
||||||
<FormDescription>
|
|
||||||
You can manage your mobile notifications in the{" "}
|
|
||||||
<Link href="/examples/forms">mobile settings</Link> page.
|
|
||||||
</FormDescription>
|
|
||||||
</div>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<Button type="submit">Update notifications</Button>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,17 +0,0 @@
|
||||||
import { Separator } from "@/components/ui/separator"
|
|
||||||
import { NotificationsForm } from "./notifications-form"
|
|
||||||
|
|
||||||
export default function SettingsNotificationsPage() {
|
|
||||||
return (
|
|
||||||
<div className="space-y-8">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-medium">Notifications</h3>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Configure how you receive notifications.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Separator />
|
|
||||||
<NotificationsForm />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,17 +0,0 @@
|
||||||
import { Separator } from "@/components/ui/separator"
|
|
||||||
import { ProfileForm } from "@app/components/profile-form"
|
|
||||||
|
|
||||||
export default function SettingsProfilePage() {
|
|
||||||
return (
|
|
||||||
<div className="space-y-8">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-medium">Profile</h3>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
This is how others will see you on the site.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Separator />
|
|
||||||
<ProfileForm />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
5
src/app/profile/page_.tsx
Normal file
5
src/app/profile/page_.tsx
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export default async function ProfilePage() {
|
||||||
|
redirect("/profile/general");
|
||||||
|
}
|
|
@ -1,192 +0,0 @@
|
||||||
"use client"
|
|
||||||
|
|
||||||
import Link from "next/link"
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod"
|
|
||||||
import { useFieldArray, useForm } from "react-hook-form"
|
|
||||||
import { z } from "zod"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
import { toast } from "@/hooks/useToast"
|
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormDescription,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from "@/components/ui/form"
|
|
||||||
import { Input } from "@/components/ui/input"
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select"
|
|
||||||
import { Textarea } from "@/components/ui/textarea"
|
|
||||||
|
|
||||||
const profileFormSchema = z.object({
|
|
||||||
username: z
|
|
||||||
.string()
|
|
||||||
.min(2, {
|
|
||||||
message: "Username must be at least 2 characters.",
|
|
||||||
})
|
|
||||||
.max(30, {
|
|
||||||
message: "Username must not be longer than 30 characters.",
|
|
||||||
}),
|
|
||||||
email: z
|
|
||||||
.string({
|
|
||||||
required_error: "Please select an email to display.",
|
|
||||||
})
|
|
||||||
.email(),
|
|
||||||
bio: z.string().max(160).min(4),
|
|
||||||
urls: z
|
|
||||||
.array(
|
|
||||||
z.object({
|
|
||||||
value: z.string().url({ message: "Please enter a valid URL." }),
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.optional(),
|
|
||||||
})
|
|
||||||
|
|
||||||
type ProfileFormValues = z.infer<typeof profileFormSchema>
|
|
||||||
|
|
||||||
// This can come from your database or API.
|
|
||||||
const defaultValues: Partial<ProfileFormValues> = {
|
|
||||||
bio: "I own a computer.",
|
|
||||||
urls: [
|
|
||||||
{ value: "https://shadcn.com" },
|
|
||||||
{ value: "http://twitter.com/shadcn" },
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ProfileForm() {
|
|
||||||
const form = useForm<ProfileFormValues>({
|
|
||||||
resolver: zodResolver(profileFormSchema),
|
|
||||||
defaultValues,
|
|
||||||
mode: "onChange",
|
|
||||||
})
|
|
||||||
|
|
||||||
const { fields, append } = useFieldArray({
|
|
||||||
name: "urls",
|
|
||||||
control: form.control,
|
|
||||||
})
|
|
||||||
|
|
||||||
function onSubmit(data: ProfileFormValues) {
|
|
||||||
toast({
|
|
||||||
title: "You submitted the following values:",
|
|
||||||
description: (
|
|
||||||
<pre className="mt-2 w-[340px] rounded-md bg-slate-950 p-4">
|
|
||||||
<code className="text-white">{JSON.stringify(data, null, 2)}</code>
|
|
||||||
</pre>
|
|
||||||
),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Form {...form}>
|
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="username"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Username</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder="shadcn" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>
|
|
||||||
This is your public display name. It can be your real name or a
|
|
||||||
pseudonym. You can only change this once every 30 days.
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="email"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Email</FormLabel>
|
|
||||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
|
||||||
<FormControl>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select a verified email to display" />
|
|
||||||
</SelectTrigger>
|
|
||||||
</FormControl>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="m@example.com">m@example.com</SelectItem>
|
|
||||||
<SelectItem value="m@google.com">m@google.com</SelectItem>
|
|
||||||
<SelectItem value="m@support.com">m@support.com</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<FormDescription>
|
|
||||||
You can manage verified email addresses in your{" "}
|
|
||||||
<Link href="/examples/forms">email settings</Link>.
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="bio"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Bio</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Textarea
|
|
||||||
placeholder="Tell us a little bit about yourself"
|
|
||||||
className="resize-none"
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>
|
|
||||||
You can <span>@mention</span> other users and organizations to
|
|
||||||
link to them.
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<div>
|
|
||||||
{fields.map((field, index) => (
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
key={field.id}
|
|
||||||
name={`urls.${index}.value`}
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel className={cn(index !== 0 && "sr-only")}>
|
|
||||||
URLs
|
|
||||||
</FormLabel>
|
|
||||||
<FormDescription className={cn(index !== 0 && "sr-only")}>
|
|
||||||
Add links to your website, blog, or social media profiles.
|
|
||||||
</FormDescription>
|
|
||||||
<FormControl>
|
|
||||||
<Input {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="mt-2"
|
|
||||||
onClick={() => append({ value: "" })}
|
|
||||||
>
|
|
||||||
Add URL
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<Button type="submit">Update profile</Button>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -188,7 +188,7 @@ export default function StepperForm() {
|
||||||
<Form {...orgForm}>
|
<Form {...orgForm}>
|
||||||
<form
|
<form
|
||||||
onSubmit={orgForm.handleSubmit(orgSubmit)}
|
onSubmit={orgForm.handleSubmit(orgSubmit)}
|
||||||
className="space-y-8"
|
className="space-y-4"
|
||||||
>
|
>
|
||||||
<FormField
|
<FormField
|
||||||
control={orgForm.control}
|
control={orgForm.control}
|
||||||
|
|
291
src/components/Enable2FaForm.tsx
Normal file
291
src/components/Enable2FaForm.tsx
Normal file
|
@ -0,0 +1,291 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { AlertCircle, CheckCircle2 } from "lucide-react";
|
||||||
|
import { createApiClient } from "@app/api";
|
||||||
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
|
import { AxiosResponse } from "axios";
|
||||||
|
import {
|
||||||
|
RequestTotpSecretBody,
|
||||||
|
RequestTotpSecretResponse,
|
||||||
|
VerifyTotpBody,
|
||||||
|
VerifyTotpResponse
|
||||||
|
} from "@server/routers/auth";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage
|
||||||
|
} from "@app/components/ui/form";
|
||||||
|
import {
|
||||||
|
Credenza,
|
||||||
|
CredenzaBody,
|
||||||
|
CredenzaClose,
|
||||||
|
CredenzaContent,
|
||||||
|
CredenzaDescription,
|
||||||
|
CredenzaFooter,
|
||||||
|
CredenzaHeader,
|
||||||
|
CredenzaTitle
|
||||||
|
} from "@app/components/Credenza";
|
||||||
|
import { useToast } from "@app/hooks/useToast";
|
||||||
|
import { formatAxiosError } from "@app/lib/utils";
|
||||||
|
import CopyTextBox from "@app/components/CopyTextBox";
|
||||||
|
import { QRCodeSVG } from "qrcode.react";
|
||||||
|
import { userUserContext } from "@app/hooks/useUserContext";
|
||||||
|
|
||||||
|
const enableSchema = z.object({
|
||||||
|
password: z.string().min(1, { message: "Password is required" })
|
||||||
|
});
|
||||||
|
|
||||||
|
const confirmSchema = z.object({
|
||||||
|
code: z.string().length(6, { message: "Invalid code" })
|
||||||
|
});
|
||||||
|
|
||||||
|
type Enable2FaProps = {
|
||||||
|
open: boolean;
|
||||||
|
setOpen: (val: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Enable2FaForm({ open, setOpen }: Enable2FaProps) {
|
||||||
|
const [step, setStep] = useState(1);
|
||||||
|
const [secretKey, setSecretKey] = useState("");
|
||||||
|
const [verificationCode, setVerificationCode] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [success, setSuccess] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [backupCodes, setBackupCodes] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const { user, updateUser } = userUserContext();
|
||||||
|
|
||||||
|
const api = createApiClient(useEnvContext());
|
||||||
|
|
||||||
|
const enableForm = useForm<z.infer<typeof enableSchema>>({
|
||||||
|
resolver: zodResolver(enableSchema),
|
||||||
|
defaultValues: {
|
||||||
|
password: ""
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const confirmForm = useForm<z.infer<typeof confirmSchema>>({
|
||||||
|
resolver: zodResolver(confirmSchema),
|
||||||
|
defaultValues: {
|
||||||
|
code: ""
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const request2fa = async (values: z.infer<typeof enableSchema>) => {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
const res = await api
|
||||||
|
.post<AxiosResponse<RequestTotpSecretResponse>>(
|
||||||
|
`/auth/2fa/request`,
|
||||||
|
{
|
||||||
|
password: values.password
|
||||||
|
} as RequestTotpSecretBody
|
||||||
|
)
|
||||||
|
.catch((e) => {
|
||||||
|
toast({
|
||||||
|
title: "Unable to enable 2FA",
|
||||||
|
description: formatAxiosError(
|
||||||
|
e,
|
||||||
|
"An error occurred while enabling 2FA"
|
||||||
|
),
|
||||||
|
variant: "destructive"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res && res.data.data.secret) {
|
||||||
|
setSecretKey(res.data.data.secret);
|
||||||
|
setStep(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirm2fa = async (values: z.infer<typeof confirmSchema>) => {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
const res = await api
|
||||||
|
.post<AxiosResponse<VerifyTotpResponse>>(`/auth/2fa/enable`, {
|
||||||
|
code: values.code
|
||||||
|
} as VerifyTotpBody)
|
||||||
|
.catch((e) => {
|
||||||
|
toast({
|
||||||
|
title: "Unable to enable 2FA",
|
||||||
|
description: formatAxiosError(
|
||||||
|
e,
|
||||||
|
"An error occurred while enabling 2FA"
|
||||||
|
),
|
||||||
|
variant: "destructive"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res && res.data.data.valid) {
|
||||||
|
setBackupCodes(res.data.data.backupCodes || []);
|
||||||
|
updateUser({ twoFactorEnabled: true })
|
||||||
|
setStep(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVerify = () => {
|
||||||
|
if (verificationCode.length !== 6) {
|
||||||
|
setError("Please enter a 6-digit code");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (verificationCode === "123456") {
|
||||||
|
setSuccess(true);
|
||||||
|
setStep(3);
|
||||||
|
} else {
|
||||||
|
setError("Invalid code. Please try again.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Credenza
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(val) => {
|
||||||
|
setOpen(val);
|
||||||
|
setLoading(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CredenzaContent>
|
||||||
|
<CredenzaHeader>
|
||||||
|
<CredenzaTitle>
|
||||||
|
Enable Two-factor Authentication
|
||||||
|
</CredenzaTitle>
|
||||||
|
<CredenzaDescription>
|
||||||
|
Secure your account with an extra layer of protection
|
||||||
|
</CredenzaDescription>
|
||||||
|
</CredenzaHeader>
|
||||||
|
<CredenzaBody>
|
||||||
|
{step === 1 && (
|
||||||
|
<Form {...enableForm}>
|
||||||
|
<form
|
||||||
|
onSubmit={enableForm.handleSubmit(request2fa)}
|
||||||
|
className="space-y-4"
|
||||||
|
id="form"
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
control={enableForm.control}
|
||||||
|
name="password"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Password</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
placeholder="Enter your password"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 2 && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p>
|
||||||
|
scan this qr code with your authenticator app or
|
||||||
|
enter the secret key manually:
|
||||||
|
</p>
|
||||||
|
<div classname="w-64 h-64 mx-auto flex items-center justify-center">
|
||||||
|
<qrcodesvg value={secretkey} size={256} />
|
||||||
|
</div>
|
||||||
|
<div className="max-w-md mx-auto">
|
||||||
|
<CopyTextBox
|
||||||
|
text={secretKey}
|
||||||
|
wrapText={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Form {...confirmForm}>
|
||||||
|
<form
|
||||||
|
onSubmit={confirmForm.handleSubmit(
|
||||||
|
confirm2fa
|
||||||
|
)}
|
||||||
|
className="space-y-4"
|
||||||
|
id="form"
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
control={confirmForm.control}
|
||||||
|
name="code"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
Verification Code
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="code"
|
||||||
|
placeholder="Enter the 6-digit code from your authenticator app"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 3 && (
|
||||||
|
<div className="space-y-4 text-center">
|
||||||
|
<CheckCircle2
|
||||||
|
className="mx-auto text-green-500"
|
||||||
|
size={48}
|
||||||
|
/>
|
||||||
|
<p className="font-semibold text-lg">
|
||||||
|
Two-Factor Authentication Enabled
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Your account is now more secure. Don't forget to
|
||||||
|
save your backup codes.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="max-w-md mx-auto">
|
||||||
|
<CopyTextBox text={backupCodes.join("\n")} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CredenzaBody>
|
||||||
|
<CredenzaFooter>
|
||||||
|
{(step === 1 || step === 2) && (
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
form="form"
|
||||||
|
loading={loading}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
Submit
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<CredenzaClose asChild>
|
||||||
|
<Button variant="outline">Close</Button>
|
||||||
|
</CredenzaClose>
|
||||||
|
</CredenzaFooter>
|
||||||
|
</CredenzaContent>
|
||||||
|
</Credenza>
|
||||||
|
);
|
||||||
|
}
|
284
src/components/Header.tsx
Normal file
284
src/components/Header.tsx
Normal file
|
@ -0,0 +1,284 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createApiClient } from "@app/api";
|
||||||
|
import { Avatar, AvatarFallback } from "@app/components/ui/avatar";
|
||||||
|
import { Button } from "@app/components/ui/button";
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
CommandSeparator
|
||||||
|
} from "@app/components/ui/command";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger
|
||||||
|
} from "@app/components/ui/dropdown-menu";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger
|
||||||
|
} from "@app/components/ui/popover";
|
||||||
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
|
import { useToast } from "@app/hooks/useToast";
|
||||||
|
import { cn, formatAxiosError } from "@app/lib/utils";
|
||||||
|
import { ListOrgsResponse } from "@server/routers/org";
|
||||||
|
import {
|
||||||
|
Check,
|
||||||
|
ChevronsUpDown,
|
||||||
|
Laptop,
|
||||||
|
LogOut,
|
||||||
|
Moon,
|
||||||
|
Plus,
|
||||||
|
Sun
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useTheme } from "next-themes";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
|
import Enable2FaForm from "./Enable2FaForm";
|
||||||
|
import { userUserContext } from "@app/hooks/useUserContext";
|
||||||
|
|
||||||
|
type HeaderProps = {
|
||||||
|
orgId?: string;
|
||||||
|
orgs?: ListOrgsResponse["orgs"];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Header({ orgId, orgs }: HeaderProps) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const { setTheme, theme } = useTheme();
|
||||||
|
|
||||||
|
const { user, updateUser } = userUserContext();
|
||||||
|
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [userTheme, setUserTheme] = useState<"light" | "dark" | "system">(
|
||||||
|
theme as "light" | "dark" | "system"
|
||||||
|
);
|
||||||
|
|
||||||
|
const [openEnable2fa, setOpenEnable2fa] = useState(false);
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const api = createApiClient(useEnvContext());
|
||||||
|
|
||||||
|
function getInitials() {
|
||||||
|
return user.email.substring(0, 2).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
api.post("/auth/logout")
|
||||||
|
.catch((e) => {
|
||||||
|
console.error("Error logging out", e);
|
||||||
|
toast({
|
||||||
|
title: "Error logging out",
|
||||||
|
description: formatAxiosError(e, "Error logging out")
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
router.push("/auth/login");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleThemeChange(theme: "light" | "dark" | "system") {
|
||||||
|
setUserTheme(theme);
|
||||||
|
setTheme(theme);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Enable2FaForm open={openEnable2fa} setOpen={setOpenEnable2fa} />
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="relative h-10 w-10 rounded-full"
|
||||||
|
>
|
||||||
|
<Avatar className="h-9 w-9">
|
||||||
|
<AvatarFallback>
|
||||||
|
{getInitials()}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent
|
||||||
|
className="w-56"
|
||||||
|
align="start"
|
||||||
|
forceMount
|
||||||
|
>
|
||||||
|
<DropdownMenuLabel className="font-normal">
|
||||||
|
<div className="flex flex-col space-y-1">
|
||||||
|
<p className="text-sm font-medium leading-none">
|
||||||
|
Signed in as
|
||||||
|
</p>
|
||||||
|
<p className="text-xs leading-none text-muted-foreground">
|
||||||
|
{user.email}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
{!user.twoFactorEnabled && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => setOpenEnable2fa(true)}
|
||||||
|
>
|
||||||
|
<span>Enable Two-factor</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{user.twoFactorEnabled && (
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<span>Disable Two-factor</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuLabel>Theme</DropdownMenuLabel>
|
||||||
|
{(["light", "dark", "system"] as const).map(
|
||||||
|
(themeOption) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={themeOption}
|
||||||
|
onClick={() =>
|
||||||
|
handleThemeChange(themeOption)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{themeOption === "light" && (
|
||||||
|
<Sun className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{themeOption === "dark" && (
|
||||||
|
<Moon className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{themeOption === "system" && (
|
||||||
|
<Laptop className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
<span className="capitalize">
|
||||||
|
{themeOption}
|
||||||
|
</span>
|
||||||
|
{userTheme === themeOption && (
|
||||||
|
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<span className="h-2 w-2 rounded-full bg-primary"></span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onClick={() => logout()}>
|
||||||
|
<LogOut className="mr-2 h-4 w-4" />
|
||||||
|
<span>Log out</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
<span className="truncate max-w-[150px] md:max-w-none font-medium">
|
||||||
|
{user.email}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="hidden md:block">
|
||||||
|
<div className="flex items-center gap-4 mr-4">
|
||||||
|
<Link
|
||||||
|
href="/docs"
|
||||||
|
className="text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
Documentation
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/support"
|
||||||
|
className="text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
Support
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{orgs && (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="lg"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={open}
|
||||||
|
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">
|
||||||
|
<span className="font-bold text-sm">
|
||||||
|
Organization
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{orgId
|
||||||
|
? orgs?.find(
|
||||||
|
(org) =>
|
||||||
|
org.orgId ===
|
||||||
|
orgId
|
||||||
|
)?.name
|
||||||
|
: "None selected"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="[100px] md:w-[180px] p-0">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="Search..." />
|
||||||
|
<CommandEmpty>
|
||||||
|
No organizations found.
|
||||||
|
</CommandEmpty>
|
||||||
|
<CommandGroup heading="Create">
|
||||||
|
<CommandList>
|
||||||
|
<CommandItem
|
||||||
|
onSelect={(currentValue) => {
|
||||||
|
router.push("/setup");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
New Organization
|
||||||
|
</CommandItem>
|
||||||
|
</CommandList>
|
||||||
|
</CommandGroup>
|
||||||
|
<CommandSeparator />
|
||||||
|
<CommandGroup heading="Organizations">
|
||||||
|
<CommandList>
|
||||||
|
{orgs.map((org) => (
|
||||||
|
<CommandItem
|
||||||
|
key={org.orgId}
|
||||||
|
onSelect={(
|
||||||
|
currentValue
|
||||||
|
) => {
|
||||||
|
router.push(
|
||||||
|
`/${org.orgId}/settings`
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4",
|
||||||
|
orgId === org.orgId
|
||||||
|
? "opacity-100"
|
||||||
|
: "opacity-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{org.name}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandList>
|
||||||
|
</CommandGroup>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Header;
|
|
@ -12,14 +12,14 @@ import {
|
||||||
FormField,
|
FormField,
|
||||||
FormItem,
|
FormItem,
|
||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage,
|
FormMessage
|
||||||
} from "@/components/ui/form";
|
} from "@/components/ui/form";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
CardDescription,
|
CardDescription,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import { LoginResponse } from "@server/routers/auth";
|
import { LoginResponse } from "@server/routers/auth";
|
||||||
|
@ -29,6 +29,14 @@ import { formatAxiosError } from "@app/lib/utils";
|
||||||
import { LockIcon } from "lucide-react";
|
import { LockIcon } from "lucide-react";
|
||||||
import { createApiClient } from "@app/api";
|
import { createApiClient } from "@app/api";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
|
import {
|
||||||
|
InputOTP,
|
||||||
|
InputOTPGroup,
|
||||||
|
InputOTPSeparator,
|
||||||
|
InputOTPSlot
|
||||||
|
} from "./ui/input-otp";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp";
|
||||||
|
|
||||||
type LoginFormProps = {
|
type LoginFormProps = {
|
||||||
redirect?: string;
|
redirect?: string;
|
||||||
|
@ -39,7 +47,11 @@ const formSchema = z.object({
|
||||||
email: z.string().email({ message: "Invalid email address" }),
|
email: z.string().email({ message: "Invalid email address" }),
|
||||||
password: z
|
password: z
|
||||||
.string()
|
.string()
|
||||||
.min(8, { message: "Password must be at least 8 characters" }),
|
.min(8, { message: "Password must be at least 8 characters" })
|
||||||
|
});
|
||||||
|
|
||||||
|
const mfaSchema = z.object({
|
||||||
|
code: z.string().length(6, { message: "Invalid code" })
|
||||||
});
|
});
|
||||||
|
|
||||||
export default function LoginForm({ redirect, onLogin }: LoginFormProps) {
|
export default function LoginForm({ redirect, onLogin }: LoginFormProps) {
|
||||||
|
@ -50,17 +62,26 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) {
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const [mfaRequested, setMfaRequested] = useState(false);
|
||||||
|
|
||||||
const form = useForm<z.infer<typeof formSchema>>({
|
const form = useForm<z.infer<typeof formSchema>>({
|
||||||
resolver: zodResolver(formSchema),
|
resolver: zodResolver(formSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
email: "",
|
email: "",
|
||||||
password: "",
|
password: ""
|
||||||
},
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
async function onSubmit(values: z.infer<typeof formSchema>) {
|
const mfaForm = useForm<z.infer<typeof mfaSchema>>({
|
||||||
const { email, password } = values;
|
resolver: zodResolver(mfaSchema),
|
||||||
|
defaultValues: {
|
||||||
|
code: ""
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function onSubmit(values: any) {
|
||||||
|
const { email, password } = form.getValues();
|
||||||
|
const { code } = mfaForm.getValues();
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
|
@ -68,18 +89,30 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) {
|
||||||
.post<AxiosResponse<LoginResponse>>("/auth/login", {
|
.post<AxiosResponse<LoginResponse>>("/auth/login", {
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
|
code
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
setError(
|
setError(
|
||||||
formatAxiosError(e, "An error occurred while logging in"),
|
formatAxiosError(e, "An error occurred while logging in")
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res && res.status === 200) {
|
if (res) {
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
if (res.data?.data?.emailVerificationRequired) {
|
const data = res.data.data;
|
||||||
|
|
||||||
|
console.log(data);
|
||||||
|
|
||||||
|
if (data?.codeRequested) {
|
||||||
|
setMfaRequested(true);
|
||||||
|
setLoading(false);
|
||||||
|
mfaForm.reset();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data?.emailVerificationRequired) {
|
||||||
if (redirect) {
|
if (redirect) {
|
||||||
router.push(`/auth/verify-email?redirect=${redirect}`);
|
router.push(`/auth/verify-email?redirect=${redirect}`);
|
||||||
} else {
|
} else {
|
||||||
|
@ -97,8 +130,13 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
{!mfaRequested && (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
|
<form
|
||||||
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
|
className="space-y-4"
|
||||||
|
>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="email"
|
name="email"
|
||||||
|
@ -115,6 +153,8 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) {
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="password"
|
name="password"
|
||||||
|
@ -132,16 +172,103 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) {
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<Link
|
||||||
|
href={`/auth/reset-password${form.getValues().email ? `?email=${form.getValues().email}` : ""}`}
|
||||||
|
className="text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
Forgot password?
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
<AlertDescription>{error}</AlertDescription>
|
<AlertDescription>{error}</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
<Button type="submit" className="w-full" loading={loading}>
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="w-full"
|
||||||
|
loading={loading}
|
||||||
|
>
|
||||||
<LockIcon className="w-4 h-4 mr-2" />
|
<LockIcon className="w-4 h-4 mr-2" />
|
||||||
Login
|
Login
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mfaRequested && (
|
||||||
|
<Form {...mfaForm}>
|
||||||
|
<form
|
||||||
|
onSubmit={mfaForm.handleSubmit(onSubmit)}
|
||||||
|
className="space-y-4"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={mfaForm.control}
|
||||||
|
name="code"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Authenticator Code</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<InputOTP
|
||||||
|
maxLength={6}
|
||||||
|
{...field}
|
||||||
|
pattern={
|
||||||
|
REGEXP_ONLY_DIGITS_AND_CHARS
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<InputOTPGroup>
|
||||||
|
<InputOTPSlot index={0} />
|
||||||
|
<InputOTPSlot index={1} />
|
||||||
|
<InputOTPSlot index={2} />
|
||||||
|
</InputOTPGroup>
|
||||||
|
<InputOTPSeparator />
|
||||||
|
<InputOTPGroup>
|
||||||
|
<InputOTPSlot index={3} />
|
||||||
|
<InputOTPSlot index={4} />
|
||||||
|
<InputOTPSlot index={5} />
|
||||||
|
</InputOTPGroup>
|
||||||
|
</InputOTP>
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{error && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="w-full"
|
||||||
|
loading={loading}
|
||||||
|
>
|
||||||
|
<LockIcon className="w-4 h-4 mr-2" />
|
||||||
|
Submit Code
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="w-full"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setMfaRequested(false);
|
||||||
|
mfaForm.reset();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Back to Login
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,7 +12,7 @@ interface TopbarNavProps extends React.HTMLAttributes<HTMLElement> {
|
||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
}[];
|
}[];
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
orgId: string;
|
orgId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TopbarNav({
|
export function TopbarNav({
|
||||||
|
@ -36,10 +36,10 @@ export function TopbarNav({
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<Link
|
<Link
|
||||||
key={item.href}
|
key={item.href}
|
||||||
href={item.href.replace("{orgId}", orgId)}
|
href={item.href.replace("{orgId}", orgId || "")}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative px-3 py-3 text-md",
|
"relative px-3 py-3 text-md",
|
||||||
pathname.startsWith(item.href.replace("{orgId}", orgId))
|
pathname.startsWith(item.href.replace("{orgId}", orgId || ""))
|
||||||
? "border-b-2 border-primary text-primary font-medium"
|
? "border-b-2 border-primary text-primary font-medium"
|
||||||
: "hover:text-primary text-muted-foreground font-medium",
|
: "hover:text-primary text-muted-foreground font-medium",
|
||||||
"whitespace-nowrap",
|
"whitespace-nowrap",
|
|
@ -1,176 +0,0 @@
|
||||||
"use client"
|
|
||||||
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod"
|
|
||||||
import { CalendarIcon, CaretSortIcon, CheckIcon } from "@radix-ui/react-icons"
|
|
||||||
import { useForm } from "react-hook-form"
|
|
||||||
import { z } from "zod"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
import { toast } from "@/hooks/useToast"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import {
|
|
||||||
Command,
|
|
||||||
CommandEmpty,
|
|
||||||
CommandGroup,
|
|
||||||
CommandInput,
|
|
||||||
CommandItem,
|
|
||||||
CommandList,
|
|
||||||
} from "@/components/ui/command"
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormDescription,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from "@/components/ui/form"
|
|
||||||
import { Input } from "@/components/ui/input"
|
|
||||||
import {
|
|
||||||
Popover,
|
|
||||||
PopoverContent,
|
|
||||||
PopoverTrigger,
|
|
||||||
} from "@/components/ui/popover"
|
|
||||||
|
|
||||||
const languages = [
|
|
||||||
{ label: "English", value: "en" },
|
|
||||||
{ label: "French", value: "fr" },
|
|
||||||
{ label: "German", value: "de" },
|
|
||||||
{ label: "Spanish", value: "es" },
|
|
||||||
{ label: "Portuguese", value: "pt" },
|
|
||||||
{ label: "Russian", value: "ru" },
|
|
||||||
{ label: "Japanese", value: "ja" },
|
|
||||||
{ label: "Korean", value: "ko" },
|
|
||||||
{ label: "Chinese", value: "zh" },
|
|
||||||
] as const
|
|
||||||
|
|
||||||
const accountFormSchema = z.object({
|
|
||||||
name: z
|
|
||||||
.string()
|
|
||||||
.min(2, {
|
|
||||||
message: "Name must be at least 2 characters.",
|
|
||||||
})
|
|
||||||
.max(30, {
|
|
||||||
message: "Name must not be longer than 30 characters.",
|
|
||||||
}),
|
|
||||||
dob: z.date({
|
|
||||||
required_error: "A date of birth is required.",
|
|
||||||
}),
|
|
||||||
language: z.string({
|
|
||||||
required_error: "Please select a language.",
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
|
|
||||||
type AccountFormValues = z.infer<typeof accountFormSchema>
|
|
||||||
|
|
||||||
// This can come from your database or API.
|
|
||||||
const defaultValues: Partial<AccountFormValues> = {
|
|
||||||
// name: "Your name",
|
|
||||||
// dob: new Date("2023-01-23"),
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AccountForm() {
|
|
||||||
const form = useForm<AccountFormValues>({
|
|
||||||
resolver: zodResolver(accountFormSchema),
|
|
||||||
defaultValues,
|
|
||||||
})
|
|
||||||
|
|
||||||
function onSubmit(data: AccountFormValues) {
|
|
||||||
toast({
|
|
||||||
title: "You submitted the following values:",
|
|
||||||
description: (
|
|
||||||
<pre className="mt-2 w-[340px] rounded-md bg-slate-950 p-4">
|
|
||||||
<code className="text-white">{JSON.stringify(data, null, 2)}</code>
|
|
||||||
</pre>
|
|
||||||
),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Form {...form}>
|
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="name"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Name</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder="Your name" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>
|
|
||||||
This is the name that will be displayed on your profile and in
|
|
||||||
emails.
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="language"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="flex flex-col">
|
|
||||||
<FormLabel>Language</FormLabel>
|
|
||||||
<Popover>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<FormControl>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
role="combobox"
|
|
||||||
className={cn(
|
|
||||||
"w-[200px] justify-between",
|
|
||||||
!field.value && "text-muted-foreground"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{field.value
|
|
||||||
? languages.find(
|
|
||||||
(language) => language.value === field.value
|
|
||||||
)?.label
|
|
||||||
: "Select language"}
|
|
||||||
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
|
||||||
</Button>
|
|
||||||
</FormControl>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="w-[200px] p-0">
|
|
||||||
<Command>
|
|
||||||
<CommandInput placeholder="Search language..." />
|
|
||||||
<CommandList>
|
|
||||||
<CommandEmpty>No language found.</CommandEmpty>
|
|
||||||
<CommandGroup>
|
|
||||||
{languages.map((language) => (
|
|
||||||
<CommandItem
|
|
||||||
value={language.label}
|
|
||||||
key={language.value}
|
|
||||||
onSelect={() => {
|
|
||||||
form.setValue("language", language.value)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CheckIcon
|
|
||||||
className={cn(
|
|
||||||
"mr-2 h-4 w-4",
|
|
||||||
language.value === field.value
|
|
||||||
? "opacity-100"
|
|
||||||
: "opacity-0"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
{language.label}
|
|
||||||
</CommandItem>
|
|
||||||
))}
|
|
||||||
</CommandGroup>
|
|
||||||
</CommandList>
|
|
||||||
</Command>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
<FormDescription>
|
|
||||||
This is the language that will be used in the dashboard.
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<Button type="submit">Update account</Button>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,179 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import { ChevronDownIcon } from "@radix-ui/react-icons";
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { toast } from "@/hooks/useToast";
|
|
||||||
import { Button, buttonVariants } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormDescription,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from "@/components/ui/form";
|
|
||||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
|
||||||
import { useSiteContext } from "@app/hooks/useSiteContext";
|
|
||||||
|
|
||||||
const appearanceFormSchema = z.object({
|
|
||||||
theme: z.enum(["light", "dark"], {
|
|
||||||
required_error: "Please select a theme.",
|
|
||||||
}),
|
|
||||||
font: z.enum(["inter", "manrope", "system"], {
|
|
||||||
invalid_type_error: "Select a font",
|
|
||||||
required_error: "Please select a font.",
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
type AppearanceFormValues = z.infer<typeof appearanceFormSchema>;
|
|
||||||
|
|
||||||
// This can come from your database or API.
|
|
||||||
const defaultValues: Partial<AppearanceFormValues> = {
|
|
||||||
theme: "light",
|
|
||||||
};
|
|
||||||
|
|
||||||
export function AppearanceForm() {
|
|
||||||
const site = useSiteContext();
|
|
||||||
|
|
||||||
console.log(site);
|
|
||||||
|
|
||||||
const form = useForm<AppearanceFormValues>({
|
|
||||||
resolver: zodResolver(appearanceFormSchema),
|
|
||||||
defaultValues,
|
|
||||||
});
|
|
||||||
|
|
||||||
function onSubmit(data: AppearanceFormValues) {
|
|
||||||
toast({
|
|
||||||
title: "You submitted the following values:",
|
|
||||||
description: (
|
|
||||||
<pre className="mt-2 w-[340px] rounded-md bg-slate-950 p-4">
|
|
||||||
<code className="text-white">
|
|
||||||
{JSON.stringify(data, null, 2)}
|
|
||||||
</code>
|
|
||||||
</pre>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Form {...form}>
|
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="font"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Font</FormLabel>
|
|
||||||
<div className="relative w-max">
|
|
||||||
<FormControl>
|
|
||||||
<select
|
|
||||||
className={cn(
|
|
||||||
buttonVariants({
|
|
||||||
variant: "outline",
|
|
||||||
}),
|
|
||||||
"w-[200px] appearance-none font-normal"
|
|
||||||
)}
|
|
||||||
{...field}
|
|
||||||
>
|
|
||||||
<option value="inter">Inter</option>
|
|
||||||
<option value="manrope">Manrope</option>
|
|
||||||
<option value="system">System</option>
|
|
||||||
</select>
|
|
||||||
</FormControl>
|
|
||||||
<ChevronDownIcon className="absolute right-3 top-2.5 h-4 w-4 opacity-50" />
|
|
||||||
</div>
|
|
||||||
<FormDescription>
|
|
||||||
Set the font you want to use in the dashboard.
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="theme"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="space-y-1">
|
|
||||||
<FormLabel>Theme</FormLabel>
|
|
||||||
<FormDescription>
|
|
||||||
Select the theme for the dashboard.
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
<RadioGroup
|
|
||||||
onValueChange={field.onChange}
|
|
||||||
defaultValue={field.value}
|
|
||||||
className="grid max-w-md grid-cols-2 gap-8 pt-2"
|
|
||||||
>
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel className="[&:has([data-state=checked])>div]:border-primary">
|
|
||||||
<FormControl>
|
|
||||||
<RadioGroupItem
|
|
||||||
value="light"
|
|
||||||
className="sr-only"
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<div className="items-center rounded-md border-2 border-muted p-1 hover:border-accent">
|
|
||||||
<div className="space-y-2 rounded-sm bg-[#ecedef] p-2">
|
|
||||||
<div className="space-y-2 rounded-md bg-white p-2 shadow-sm">
|
|
||||||
<div className="h-2 w-[80px] rounded-lg bg-[#ecedef]" />
|
|
||||||
<div className="h-2 w-[100px] rounded-lg bg-[#ecedef]" />
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-2 rounded-md bg-white p-2 shadow-sm">
|
|
||||||
<div className="h-4 w-4 rounded-full bg-[#ecedef]" />
|
|
||||||
<div className="h-2 w-[100px] rounded-lg bg-[#ecedef]" />
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-2 rounded-md bg-white p-2 shadow-sm">
|
|
||||||
<div className="h-4 w-4 rounded-full bg-[#ecedef]" />
|
|
||||||
<div className="h-2 w-[100px] rounded-lg bg-[#ecedef]" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span className="block w-full p-2 text-center font-normal">
|
|
||||||
Light
|
|
||||||
</span>
|
|
||||||
</FormLabel>
|
|
||||||
</FormItem>
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel className="[&:has([data-state=checked])>div]:border-primary">
|
|
||||||
<FormControl>
|
|
||||||
<RadioGroupItem
|
|
||||||
value="dark"
|
|
||||||
className="sr-only"
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<div className="items-center rounded-md border-2 border-muted bg-popover p-1 hover:bg-accent hover:text-accent-foreground">
|
|
||||||
<div className="space-y-2 rounded-sm bg-slate-950 p-2">
|
|
||||||
<div className="space-y-2 rounded-md bg-slate-800 p-2 shadow-sm">
|
|
||||||
<div className="h-2 w-[80px] rounded-lg bg-slate-400" />
|
|
||||||
<div className="h-2 w-[100px] rounded-lg bg-slate-400" />
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-2 rounded-md bg-slate-800 p-2 shadow-sm">
|
|
||||||
<div className="h-4 w-4 rounded-full bg-slate-400" />
|
|
||||||
<div className="h-2 w-[100px] rounded-lg bg-slate-400" />
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-2 rounded-md bg-slate-800 p-2 shadow-sm">
|
|
||||||
<div className="h-4 w-4 rounded-full bg-slate-400" />
|
|
||||||
<div className="h-2 w-[100px] rounded-lg bg-slate-400" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span className="block w-full p-2 text-center font-normal">
|
|
||||||
Dark
|
|
||||||
</span>
|
|
||||||
</FormLabel>
|
|
||||||
</FormItem>
|
|
||||||
</RadioGroup>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Button type="submit">Update preferences</Button>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -1,132 +0,0 @@
|
||||||
"use client"
|
|
||||||
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod"
|
|
||||||
import { useForm } from "react-hook-form"
|
|
||||||
import { z } from "zod"
|
|
||||||
|
|
||||||
import { toast } from "@/hooks/useToast"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { Checkbox } from "@/components/ui/checkbox"
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormDescription,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from "@/components/ui/form"
|
|
||||||
|
|
||||||
const items = [
|
|
||||||
{
|
|
||||||
id: "recents",
|
|
||||||
label: "Recents",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "home",
|
|
||||||
label: "Home",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "applications",
|
|
||||||
label: "Applications",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "desktop",
|
|
||||||
label: "Desktop",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "downloads",
|
|
||||||
label: "Downloads",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "documents",
|
|
||||||
label: "Documents",
|
|
||||||
},
|
|
||||||
] as const
|
|
||||||
|
|
||||||
const displayFormSchema = z.object({
|
|
||||||
items: z.array(z.string()).refine((value) => value.some((item) => item), {
|
|
||||||
message: "You have to select at least one item.",
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
|
|
||||||
type DisplayFormValues = z.infer<typeof displayFormSchema>
|
|
||||||
|
|
||||||
// This can come from your database or API.
|
|
||||||
const defaultValues: Partial<DisplayFormValues> = {
|
|
||||||
items: ["recents", "home"],
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DisplayForm() {
|
|
||||||
const form = useForm<DisplayFormValues>({
|
|
||||||
resolver: zodResolver(displayFormSchema),
|
|
||||||
defaultValues,
|
|
||||||
})
|
|
||||||
|
|
||||||
function onSubmit(data: DisplayFormValues) {
|
|
||||||
toast({
|
|
||||||
title: "You submitted the following values:",
|
|
||||||
description: (
|
|
||||||
<pre className="mt-2 w-[340px] rounded-md bg-slate-950 p-4">
|
|
||||||
<code className="text-white">{JSON.stringify(data, null, 2)}</code>
|
|
||||||
</pre>
|
|
||||||
),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Form {...form}>
|
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="items"
|
|
||||||
render={() => (
|
|
||||||
<FormItem>
|
|
||||||
<div className="mb-4">
|
|
||||||
<FormLabel className="text-base">Sidebar</FormLabel>
|
|
||||||
<FormDescription>
|
|
||||||
Select the items you want to display in the sidebar.
|
|
||||||
</FormDescription>
|
|
||||||
</div>
|
|
||||||
{items.map((item) => (
|
|
||||||
<FormField
|
|
||||||
key={item.id}
|
|
||||||
control={form.control}
|
|
||||||
name="items"
|
|
||||||
render={({ field }) => {
|
|
||||||
return (
|
|
||||||
<FormItem
|
|
||||||
key={item.id}
|
|
||||||
className="flex flex-row items-start space-x-3 space-y-0"
|
|
||||||
>
|
|
||||||
<FormControl>
|
|
||||||
<Checkbox
|
|
||||||
checked={field.value?.includes(item.id)}
|
|
||||||
onCheckedChange={(checked) => {
|
|
||||||
return checked
|
|
||||||
? field.onChange([...field.value, item.id])
|
|
||||||
: field.onChange(
|
|
||||||
field.value?.filter(
|
|
||||||
(value) => value !== item.id
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormLabel className="font-normal">
|
|
||||||
{item.label}
|
|
||||||
</FormLabel>
|
|
||||||
</FormItem>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<Button type="submit">Update display</Button>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,222 +0,0 @@
|
||||||
"use client"
|
|
||||||
|
|
||||||
import Link from "next/link"
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod"
|
|
||||||
import { useForm } from "react-hook-form"
|
|
||||||
import { z } from "zod"
|
|
||||||
|
|
||||||
import { toast } from "@/hooks/useToast"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { Checkbox } from "@/components/ui/checkbox"
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormDescription,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from "@/components/ui/form"
|
|
||||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
|
|
||||||
import { Switch } from "@/components/ui/switch"
|
|
||||||
|
|
||||||
const notificationsFormSchema = z.object({
|
|
||||||
type: z.enum(["all", "mentions", "none"], {
|
|
||||||
required_error: "You need to select a notification type.",
|
|
||||||
}),
|
|
||||||
mobile: z.boolean().default(false).optional(),
|
|
||||||
communication_emails: z.boolean().default(false).optional(),
|
|
||||||
social_emails: z.boolean().default(false).optional(),
|
|
||||||
marketing_emails: z.boolean().default(false).optional(),
|
|
||||||
security_emails: z.boolean(),
|
|
||||||
})
|
|
||||||
|
|
||||||
type NotificationsFormValues = z.infer<typeof notificationsFormSchema>
|
|
||||||
|
|
||||||
// This can come from your database or API.
|
|
||||||
const defaultValues: Partial<NotificationsFormValues> = {
|
|
||||||
communication_emails: false,
|
|
||||||
marketing_emails: false,
|
|
||||||
social_emails: true,
|
|
||||||
security_emails: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
export function NotificationsForm() {
|
|
||||||
const form = useForm<NotificationsFormValues>({
|
|
||||||
resolver: zodResolver(notificationsFormSchema),
|
|
||||||
defaultValues,
|
|
||||||
})
|
|
||||||
|
|
||||||
function onSubmit(data: NotificationsFormValues) {
|
|
||||||
toast({
|
|
||||||
title: "You submitted the following values:",
|
|
||||||
description: (
|
|
||||||
<pre className="mt-2 w-[340px] rounded-md bg-slate-950 p-4">
|
|
||||||
<code className="text-white">{JSON.stringify(data, null, 2)}</code>
|
|
||||||
</pre>
|
|
||||||
),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Form {...form}>
|
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="type"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="space-y-3">
|
|
||||||
<FormLabel>Notify me about...</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<RadioGroup
|
|
||||||
onValueChange={field.onChange}
|
|
||||||
defaultValue={field.value}
|
|
||||||
className="flex flex-col space-y-1"
|
|
||||||
>
|
|
||||||
<FormItem className="flex items-center space-x-3 space-y-0">
|
|
||||||
<FormControl>
|
|
||||||
<RadioGroupItem value="all" />
|
|
||||||
</FormControl>
|
|
||||||
<FormLabel className="font-normal">
|
|
||||||
All new messages
|
|
||||||
</FormLabel>
|
|
||||||
</FormItem>
|
|
||||||
<FormItem className="flex items-center space-x-3 space-y-0">
|
|
||||||
<FormControl>
|
|
||||||
<RadioGroupItem value="mentions" />
|
|
||||||
</FormControl>
|
|
||||||
<FormLabel className="font-normal">
|
|
||||||
Direct messages and mentions
|
|
||||||
</FormLabel>
|
|
||||||
</FormItem>
|
|
||||||
<FormItem className="flex items-center space-x-3 space-y-0">
|
|
||||||
<FormControl>
|
|
||||||
<RadioGroupItem value="none" />
|
|
||||||
</FormControl>
|
|
||||||
<FormLabel className="font-normal">Nothing</FormLabel>
|
|
||||||
</FormItem>
|
|
||||||
</RadioGroup>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<div>
|
|
||||||
<h3 className="mb-4 text-lg font-medium">Email Notifications</h3>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="communication_emails"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<FormLabel className="text-base">
|
|
||||||
Communication emails
|
|
||||||
</FormLabel>
|
|
||||||
<FormDescription>
|
|
||||||
Receive emails about your account activity.
|
|
||||||
</FormDescription>
|
|
||||||
</div>
|
|
||||||
<FormControl>
|
|
||||||
<Switch
|
|
||||||
checked={field.value}
|
|
||||||
onCheckedChange={field.onChange}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="marketing_emails"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<FormLabel className="text-base">
|
|
||||||
Marketing emails
|
|
||||||
</FormLabel>
|
|
||||||
<FormDescription>
|
|
||||||
Receive emails about new products, features, and more.
|
|
||||||
</FormDescription>
|
|
||||||
</div>
|
|
||||||
<FormControl>
|
|
||||||
<Switch
|
|
||||||
checked={field.value}
|
|
||||||
onCheckedChange={field.onChange}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="social_emails"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<FormLabel className="text-base">Social emails</FormLabel>
|
|
||||||
<FormDescription>
|
|
||||||
Receive emails for friend requests, follows, and more.
|
|
||||||
</FormDescription>
|
|
||||||
</div>
|
|
||||||
<FormControl>
|
|
||||||
<Switch
|
|
||||||
checked={field.value}
|
|
||||||
onCheckedChange={field.onChange}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="security_emails"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<FormLabel className="text-base">Security emails</FormLabel>
|
|
||||||
<FormDescription>
|
|
||||||
Receive emails about your account activity and security.
|
|
||||||
</FormDescription>
|
|
||||||
</div>
|
|
||||||
<FormControl>
|
|
||||||
<Switch
|
|
||||||
checked={field.value}
|
|
||||||
onCheckedChange={field.onChange}
|
|
||||||
disabled
|
|
||||||
aria-readonly
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="mobile"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="flex flex-row items-start space-x-3 space-y-0">
|
|
||||||
<FormControl>
|
|
||||||
<Checkbox
|
|
||||||
checked={field.value}
|
|
||||||
onCheckedChange={field.onChange}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<div className="space-y-1 leading-none">
|
|
||||||
<FormLabel>
|
|
||||||
Use different settings for my mobile devices
|
|
||||||
</FormLabel>
|
|
||||||
<FormDescription>
|
|
||||||
You can manage your mobile notifications in the{" "}
|
|
||||||
<Link href="/examples/forms">mobile settings</Link> page.
|
|
||||||
</FormDescription>
|
|
||||||
</div>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<Button type="submit">Update notifications</Button>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,192 +0,0 @@
|
||||||
"use client"
|
|
||||||
|
|
||||||
import Link from "next/link"
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod"
|
|
||||||
import { useFieldArray, useForm } from "react-hook-form"
|
|
||||||
import { z } from "zod"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
import { toast } from "@/hooks/useToast"
|
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormDescription,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from "@/components/ui/form"
|
|
||||||
import { Input } from "@/components/ui/input"
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select"
|
|
||||||
import { Textarea } from "@/components/ui/textarea"
|
|
||||||
|
|
||||||
const profileFormSchema = z.object({
|
|
||||||
username: z
|
|
||||||
.string()
|
|
||||||
.min(2, {
|
|
||||||
message: "Username must be at least 2 characters.",
|
|
||||||
})
|
|
||||||
.max(30, {
|
|
||||||
message: "Username must not be longer than 30 characters.",
|
|
||||||
}),
|
|
||||||
email: z
|
|
||||||
.string({
|
|
||||||
required_error: "Please select an email to display.",
|
|
||||||
})
|
|
||||||
.email(),
|
|
||||||
bio: z.string().max(160).min(4),
|
|
||||||
urls: z
|
|
||||||
.array(
|
|
||||||
z.object({
|
|
||||||
value: z.string().url({ message: "Please enter a valid URL." }),
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.optional(),
|
|
||||||
})
|
|
||||||
|
|
||||||
type ProfileFormValues = z.infer<typeof profileFormSchema>
|
|
||||||
|
|
||||||
// This can come from your database or API.
|
|
||||||
const defaultValues: Partial<ProfileFormValues> = {
|
|
||||||
bio: "I own a computer.",
|
|
||||||
urls: [
|
|
||||||
{ value: "https://shadcn.com" },
|
|
||||||
{ value: "http://twitter.com/shadcn" },
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ProfileForm() {
|
|
||||||
const form = useForm<ProfileFormValues>({
|
|
||||||
resolver: zodResolver(profileFormSchema),
|
|
||||||
defaultValues,
|
|
||||||
mode: "onChange",
|
|
||||||
})
|
|
||||||
|
|
||||||
const { fields, append } = useFieldArray({
|
|
||||||
name: "urls",
|
|
||||||
control: form.control,
|
|
||||||
})
|
|
||||||
|
|
||||||
function onSubmit(data: ProfileFormValues) {
|
|
||||||
toast({
|
|
||||||
title: "You submitted the following values:",
|
|
||||||
description: (
|
|
||||||
<pre className="mt-2 w-[340px] rounded-md bg-slate-950 p-4">
|
|
||||||
<code className="text-white">{JSON.stringify(data, null, 2)}</code>
|
|
||||||
</pre>
|
|
||||||
),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Form {...form}>
|
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="username"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Username</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder="shadcn" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>
|
|
||||||
This is your public display name. It can be your real name or a
|
|
||||||
pseudonym. You can only change this once every 30 days.
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="email"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Email</FormLabel>
|
|
||||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
|
||||||
<FormControl>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select a verified email to display" />
|
|
||||||
</SelectTrigger>
|
|
||||||
</FormControl>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="m@example.com">m@example.com</SelectItem>
|
|
||||||
<SelectItem value="m@google.com">m@google.com</SelectItem>
|
|
||||||
<SelectItem value="m@support.com">m@support.com</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<FormDescription>
|
|
||||||
You can manage verified email addresses in your{" "}
|
|
||||||
<Link href="/examples/forms">email settings</Link>.
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="bio"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Bio</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Textarea
|
|
||||||
placeholder="Tell us a little bit about yourself"
|
|
||||||
className="resize-none"
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>
|
|
||||||
You can <span>@mention</span> other users and organizations to
|
|
||||||
link to them.
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<div>
|
|
||||||
{fields.map((field, index) => (
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
key={field.id}
|
|
||||||
name={`urls.${index}.value`}
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel className={cn(index !== 0 && "sr-only")}>
|
|
||||||
URLs
|
|
||||||
</FormLabel>
|
|
||||||
<FormDescription className={cn(index !== 0 && "sr-only")}>
|
|
||||||
Add links to your website, blog, or social media profiles.
|
|
||||||
</FormDescription>
|
|
||||||
<FormControl>
|
|
||||||
<Input {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="mt-2"
|
|
||||||
onClick={() => append({ value: "" })}
|
|
||||||
>
|
|
||||||
Add URL
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<Button type="submit">Update profile</Button>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,14 +1,27 @@
|
||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
import { EyeOff, Eye } from "lucide-react";
|
||||||
|
|
||||||
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
|
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
|
||||||
|
|
||||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||||
({ className, type, ...props }, ref) => {
|
({ className, type, ...props }, ref) => {
|
||||||
|
const [showPassword, setShowPassword] = React.useState(false);
|
||||||
|
const togglePasswordVisibility = () => setShowPassword(!showPassword);
|
||||||
|
|
||||||
|
console.log("type", type);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div className="relative">
|
||||||
<input
|
<input
|
||||||
type={type}
|
type={
|
||||||
|
type === "password"
|
||||||
|
? showPassword
|
||||||
|
? "text"
|
||||||
|
: "password"
|
||||||
|
: type
|
||||||
|
}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
"flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
className
|
className
|
||||||
|
@ -16,9 +29,25 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||||
ref={ref}
|
ref={ref}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
{type === "password" && (
|
||||||
|
<div className="absolute inset-y-0 right-0 flex cursor-pointer items-center pr-3 text-gray-400">
|
||||||
|
{showPassword ? (
|
||||||
|
<EyeOff
|
||||||
|
className="h-4 w-4"
|
||||||
|
onClick={togglePasswordVisibility}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Eye
|
||||||
|
className="h-4 w-4"
|
||||||
|
onClick={togglePasswordVisibility}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
)
|
);
|
||||||
Input.displayName = "Input"
|
Input.displayName = "Input";
|
||||||
|
|
||||||
export { Input }
|
export { Input };
|
||||||
|
|
|
@ -1,4 +1,11 @@
|
||||||
import { GetUserResponse } from "@server/routers/user";
|
import { GetUserResponse } from "@server/routers/user";
|
||||||
import { createContext } from "react";
|
import { createContext } from "react";
|
||||||
|
|
||||||
export const UserContext = createContext<GetUserResponse | null>(null);
|
interface UserContextType {
|
||||||
|
user: GetUserResponse;
|
||||||
|
updateUser: (updatedUser: Partial<GetUserResponse>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const UserContext = createContext<UserContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export default UserContext;
|
||||||
|
|
|
@ -1,7 +1,10 @@
|
||||||
import { UserContext } from "@app/contexts/userContext";
|
import UserContext from "@app/contexts/userContext";
|
||||||
import { useContext } from "react";
|
import { useContext } from "react";
|
||||||
|
|
||||||
export function useUserContext() {
|
export function userUserContext() {
|
||||||
const user = useContext(UserContext);
|
const context = useContext(UserContext);
|
||||||
return user;
|
if (context === undefined) {
|
||||||
|
throw new Error("useUserContext must be used within a UserProvider");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,16 +1,37 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { UserContext } from "@app/contexts/userContext";
|
import UserContext from "@app/contexts/userContext";
|
||||||
import { GetUserResponse } from "@server/routers/user";
|
import { GetUserResponse } from "@server/routers/user";
|
||||||
import { ReactNode } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
type UserProviderProps = {
|
interface UserProviderProps {
|
||||||
|
children: React.ReactNode;
|
||||||
user: GetUserResponse;
|
user: GetUserResponse;
|
||||||
children: ReactNode;
|
}
|
||||||
};
|
|
||||||
|
|
||||||
export function UserProvider({ user, children }: UserProviderProps) {
|
export function UserProvider({ children, user: u }: UserProviderProps) {
|
||||||
return <UserContext.Provider value={user}>{children}</UserContext.Provider>;
|
const [user, setUser] = useState<GetUserResponse>(u);
|
||||||
|
|
||||||
|
const updateUser = (updatedUser: Partial<GetUserResponse>) => {
|
||||||
|
if (!user) {
|
||||||
|
throw new Error("No user to update");
|
||||||
|
}
|
||||||
|
setUser((prev) => {
|
||||||
|
if (!prev) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
...updatedUser
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<UserContext.Provider value={{ user: user, updateUser: updateUser }}>
|
||||||
|
{children}
|
||||||
|
</UserContext.Provider>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default UserProvider;
|
export default UserProvider;
|
||||||
|
|
Loading…
Add table
Reference in a new issue