feat(auth): default to single-user mode with enable toggle
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
-- Add authEnabled flag to SystemConfig to support single-user mode by default.
|
||||||
|
|
||||||
|
-- SQLite supports simple ADD COLUMN for non-null with default.
|
||||||
|
ALTER TABLE "SystemConfig" ADD COLUMN "authEnabled" BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
|
||||||
@@ -32,6 +32,7 @@ model User {
|
|||||||
|
|
||||||
model SystemConfig {
|
model SystemConfig {
|
||||||
id String @id @default("default")
|
id String @id @default("default")
|
||||||
|
authEnabled Boolean @default(false)
|
||||||
registrationEnabled Boolean @default(false)
|
registrationEnabled Boolean @default(false)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|||||||
+131
-2
@@ -45,10 +45,26 @@ const ensureSystemConfig = async () => {
|
|||||||
return prisma.systemConfig.upsert({
|
return prisma.systemConfig.upsert({
|
||||||
where: { id: DEFAULT_SYSTEM_CONFIG_ID },
|
where: { id: DEFAULT_SYSTEM_CONFIG_ID },
|
||||||
update: {},
|
update: {},
|
||||||
create: { id: DEFAULT_SYSTEM_CONFIG_ID, registrationEnabled: false },
|
create: {
|
||||||
|
id: DEFAULT_SYSTEM_CONFIG_ID,
|
||||||
|
authEnabled: false,
|
||||||
|
registrationEnabled: false,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ensureAuthEnabled = async (res: Response): Promise<boolean> => {
|
||||||
|
const systemConfig = await ensureSystemConfig();
|
||||||
|
if (!systemConfig.authEnabled) {
|
||||||
|
res.status(404).json({
|
||||||
|
error: "Not found",
|
||||||
|
message: "Authentication is disabled",
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
// Rate limiting for auth endpoints (stricter than general rate limiting)
|
// Rate limiting for auth endpoints (stricter than general rate limiting)
|
||||||
const authRateLimiter = rateLimit({
|
const authRateLimiter = rateLimit({
|
||||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||||
@@ -89,6 +105,10 @@ const adminRoleUpdateSchema = z.object({
|
|||||||
role: z.enum(["ADMIN", "USER"]),
|
role: z.enum(["ADMIN", "USER"]),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const authEnabledToggleSchema = z.object({
|
||||||
|
enabled: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
const findUserByIdentifier = async (identifier: string) => {
|
const findUserByIdentifier = async (identifier: string) => {
|
||||||
const trimmed = identifier.trim();
|
const trimmed = identifier.trim();
|
||||||
if (trimmed.length === 0) return null;
|
if (trimmed.length === 0) return null;
|
||||||
@@ -150,6 +170,7 @@ const getRefreshTokenExpiresAt = (): Date =>
|
|||||||
*/
|
*/
|
||||||
router.post("/register", authRateLimiter, async (req: Request, res: Response) => {
|
router.post("/register", authRateLimiter, async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
|
if (!(await ensureAuthEnabled(res))) return;
|
||||||
const parsed = registerSchema.safeParse(req.body);
|
const parsed = registerSchema.safeParse(req.body);
|
||||||
|
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
@@ -380,6 +401,7 @@ router.post("/register", authRateLimiter, async (req: Request, res: Response) =>
|
|||||||
*/
|
*/
|
||||||
router.post("/login", authRateLimiter, async (req: Request, res: Response) => {
|
router.post("/login", authRateLimiter, async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
|
if (!(await ensureAuthEnabled(res))) return;
|
||||||
const parsed = loginSchema.safeParse(req.body);
|
const parsed = loginSchema.safeParse(req.body);
|
||||||
|
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
@@ -507,6 +529,7 @@ router.post("/login", authRateLimiter, async (req: Request, res: Response) => {
|
|||||||
*/
|
*/
|
||||||
router.post("/refresh", async (req: Request, res: Response) => {
|
router.post("/refresh", async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
|
if (!(await ensureAuthEnabled(res))) return;
|
||||||
const { refreshToken: oldRefreshToken } = req.body;
|
const { refreshToken: oldRefreshToken } = req.body;
|
||||||
|
|
||||||
if (!oldRefreshToken || typeof oldRefreshToken !== "string") {
|
if (!oldRefreshToken || typeof oldRefreshToken !== "string") {
|
||||||
@@ -639,6 +662,7 @@ router.post("/refresh", async (req: Request, res: Response) => {
|
|||||||
*/
|
*/
|
||||||
router.get("/me", requireAuth, async (req: Request, res: Response) => {
|
router.get("/me", requireAuth, async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
|
if (!(await ensureAuthEnabled(res))) return;
|
||||||
if (!req.user) {
|
if (!req.user) {
|
||||||
return res.status(401).json({
|
return res.status(401).json({
|
||||||
error: "Unauthorized",
|
error: "Unauthorized",
|
||||||
@@ -684,16 +708,31 @@ router.get("/me", requireAuth, async (req: Request, res: Response) => {
|
|||||||
router.get("/status", optionalAuth, async (req: Request, res: Response) => {
|
router.get("/status", optionalAuth, async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const systemConfig = await ensureSystemConfig();
|
const systemConfig = await ensureSystemConfig();
|
||||||
|
if (!systemConfig.authEnabled) {
|
||||||
|
return res.json({
|
||||||
|
enabled: false,
|
||||||
|
authenticated: false,
|
||||||
|
authEnabled: false,
|
||||||
|
registrationEnabled: false,
|
||||||
|
bootstrapRequired: false,
|
||||||
|
user: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const bootstrapUser = await prisma.user.findUnique({
|
const bootstrapUser = await prisma.user.findUnique({
|
||||||
where: { id: BOOTSTRAP_USER_ID },
|
where: { id: BOOTSTRAP_USER_ID },
|
||||||
select: { id: true, isActive: true },
|
select: { id: true, isActive: true },
|
||||||
});
|
});
|
||||||
|
const activeUsers = await prisma.user.count({ where: { isActive: true } });
|
||||||
|
const bootstrapRequired =
|
||||||
|
Boolean(bootstrapUser && bootstrapUser.isActive === false) && activeUsers === 0;
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
authEnabled: true,
|
||||||
authenticated: Boolean(req.user),
|
authenticated: Boolean(req.user),
|
||||||
registrationEnabled: systemConfig.registrationEnabled,
|
registrationEnabled: systemConfig.registrationEnabled,
|
||||||
bootstrapRequired: Boolean(bootstrapUser && bootstrapUser.isActive === false),
|
bootstrapRequired,
|
||||||
user: req.user
|
user: req.user
|
||||||
? {
|
? {
|
||||||
id: req.user.id,
|
id: req.user.id,
|
||||||
@@ -714,12 +753,97 @@ router.get("/status", optionalAuth, async (req: Request, res: Response) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /auth/auth-enabled
|
||||||
|
* Enable/disable authentication mode.
|
||||||
|
*
|
||||||
|
* - Enabling auth is allowed without login (single-user mode).
|
||||||
|
* - Disabling auth requires an authenticated ADMIN.
|
||||||
|
*/
|
||||||
|
router.post("/auth-enabled", optionalAuth, async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const parsed = authEnabledToggleSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: "Bad request", message: "Invalid toggle payload" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const systemConfig = await ensureSystemConfig();
|
||||||
|
const current = systemConfig.authEnabled;
|
||||||
|
const next = parsed.data.enabled;
|
||||||
|
|
||||||
|
if (current && !next) {
|
||||||
|
if (!req.user) {
|
||||||
|
return res
|
||||||
|
.status(401)
|
||||||
|
.json({ error: "Unauthorized", message: "User not authenticated" });
|
||||||
|
}
|
||||||
|
if (req.user.role !== "ADMIN") {
|
||||||
|
return res
|
||||||
|
.status(403)
|
||||||
|
.json({ error: "Forbidden", message: "Admin access required" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure the bootstrap user exists for the bootstrap registration flow.
|
||||||
|
if (!current && next) {
|
||||||
|
const bootstrap = await prisma.user.findUnique({
|
||||||
|
where: { id: BOOTSTRAP_USER_ID },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!bootstrap) {
|
||||||
|
await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
id: BOOTSTRAP_USER_ID,
|
||||||
|
email: "bootstrap@excalidash.local",
|
||||||
|
username: null,
|
||||||
|
passwordHash: "",
|
||||||
|
name: "Bootstrap Admin",
|
||||||
|
role: "ADMIN",
|
||||||
|
mustResetPassword: true,
|
||||||
|
isActive: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.systemConfig.upsert({
|
||||||
|
where: { id: DEFAULT_SYSTEM_CONFIG_ID },
|
||||||
|
update: { authEnabled: next },
|
||||||
|
create: {
|
||||||
|
id: DEFAULT_SYSTEM_CONFIG_ID,
|
||||||
|
authEnabled: next,
|
||||||
|
registrationEnabled: systemConfig.registrationEnabled,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const bootstrapUser = await prisma.user.findUnique({
|
||||||
|
where: { id: BOOTSTRAP_USER_ID },
|
||||||
|
select: { id: true, isActive: true },
|
||||||
|
});
|
||||||
|
const activeUsers = await prisma.user.count({ where: { isActive: true } });
|
||||||
|
const bootstrapRequired =
|
||||||
|
Boolean(updated.authEnabled && bootstrapUser && bootstrapUser.isActive === false) &&
|
||||||
|
activeUsers === 0;
|
||||||
|
|
||||||
|
res.json({ authEnabled: updated.authEnabled, bootstrapRequired });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Auth enabled toggle error:", error);
|
||||||
|
res.status(500).json({
|
||||||
|
error: "Internal server error",
|
||||||
|
message: "Failed to update authentication mode",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /auth/registration/toggle
|
* POST /auth/registration/toggle
|
||||||
* Enable/disable registration (admin-only)
|
* Enable/disable registration (admin-only)
|
||||||
*/
|
*/
|
||||||
router.post("/registration/toggle", requireAuth, async (req: Request, res: Response) => {
|
router.post("/registration/toggle", requireAuth, async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
|
if (!(await ensureAuthEnabled(res))) return;
|
||||||
if (!req.user) {
|
if (!req.user) {
|
||||||
return res.status(401).json({ error: "Unauthorized", message: "User not authenticated" });
|
return res.status(401).json({ error: "Unauthorized", message: "User not authenticated" });
|
||||||
}
|
}
|
||||||
@@ -754,6 +878,7 @@ router.post("/registration/toggle", requireAuth, async (req: Request, res: Respo
|
|||||||
*/
|
*/
|
||||||
router.post("/admins", requireAuth, async (req: Request, res: Response) => {
|
router.post("/admins", requireAuth, async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
|
if (!(await ensureAuthEnabled(res))) return;
|
||||||
if (!req.user) {
|
if (!req.user) {
|
||||||
return res.status(401).json({ error: "Unauthorized", message: "User not authenticated" });
|
return res.status(401).json({ error: "Unauthorized", message: "User not authenticated" });
|
||||||
}
|
}
|
||||||
@@ -805,6 +930,7 @@ const passwordResetRequestSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
router.post("/password-reset-request", authRateLimiter, async (req: Request, res: Response) => {
|
router.post("/password-reset-request", authRateLimiter, async (req: Request, res: Response) => {
|
||||||
|
if (!(await ensureAuthEnabled(res))) return;
|
||||||
// Check if password reset feature is enabled
|
// Check if password reset feature is enabled
|
||||||
if (!config.enablePasswordReset) {
|
if (!config.enablePasswordReset) {
|
||||||
return res.status(404).json({
|
return res.status(404).json({
|
||||||
@@ -901,6 +1027,7 @@ const passwordResetConfirmSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
router.post("/password-reset-confirm", authRateLimiter, async (req: Request, res: Response) => {
|
router.post("/password-reset-confirm", authRateLimiter, async (req: Request, res: Response) => {
|
||||||
|
if (!(await ensureAuthEnabled(res))) return;
|
||||||
// Check if password reset feature is enabled
|
// Check if password reset feature is enabled
|
||||||
if (!config.enablePasswordReset) {
|
if (!config.enablePasswordReset) {
|
||||||
return res.status(404).json({
|
return res.status(404).json({
|
||||||
@@ -1010,6 +1137,7 @@ const updateProfileSchema = z.object({
|
|||||||
|
|
||||||
router.put("/profile", requireAuth, async (req: Request, res: Response) => {
|
router.put("/profile", requireAuth, async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
|
if (!(await ensureAuthEnabled(res))) return;
|
||||||
if (!req.user) {
|
if (!req.user) {
|
||||||
return res.status(401).json({
|
return res.status(401).json({
|
||||||
error: "Unauthorized",
|
error: "Unauthorized",
|
||||||
@@ -1074,6 +1202,7 @@ const changePasswordSchema = z.object({
|
|||||||
|
|
||||||
router.post("/change-password", requireAuth, authRateLimiter, async (req: Request, res: Response) => {
|
router.post("/change-password", requireAuth, authRateLimiter, async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
|
if (!(await ensureAuthEnabled(res))) return;
|
||||||
if (!req.user) {
|
if (!req.user) {
|
||||||
return res.status(401).json({
|
return res.status(401).json({
|
||||||
error: "Unauthorized",
|
error: "Unauthorized",
|
||||||
|
|||||||
@@ -7,6 +7,76 @@ import { config } from "../config";
|
|||||||
import { PrismaClient } from "../generated/client";
|
import { PrismaClient } from "../generated/client";
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
const DEFAULT_SYSTEM_CONFIG_ID = "default";
|
||||||
|
const BOOTSTRAP_USER_ID = "bootstrap-admin";
|
||||||
|
|
||||||
|
type AuthEnabledCache = {
|
||||||
|
value: boolean;
|
||||||
|
fetchedAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
let authEnabledCache: AuthEnabledCache | null = null;
|
||||||
|
const AUTH_ENABLED_TTL_MS = 0;
|
||||||
|
|
||||||
|
const getAuthEnabled = async (): Promise<boolean> => {
|
||||||
|
const now = Date.now();
|
||||||
|
if (authEnabledCache && now - authEnabledCache.fetchedAt < AUTH_ENABLED_TTL_MS) {
|
||||||
|
return authEnabledCache.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const systemConfig = await prisma.systemConfig.upsert({
|
||||||
|
where: { id: DEFAULT_SYSTEM_CONFIG_ID },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
id: DEFAULT_SYSTEM_CONFIG_ID,
|
||||||
|
authEnabled: false,
|
||||||
|
registrationEnabled: false,
|
||||||
|
},
|
||||||
|
select: { authEnabled: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
authEnabledCache = { value: systemConfig.authEnabled, fetchedAt: now };
|
||||||
|
return systemConfig.authEnabled;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getBootstrapActingUser = async () => {
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: BOOTSTRAP_USER_ID },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
email: true,
|
||||||
|
name: true,
|
||||||
|
role: true,
|
||||||
|
mustResetPassword: true,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (user) return user;
|
||||||
|
|
||||||
|
return prisma.user.create({
|
||||||
|
data: {
|
||||||
|
id: BOOTSTRAP_USER_ID,
|
||||||
|
email: "bootstrap@excalidash.local",
|
||||||
|
username: null,
|
||||||
|
passwordHash: "",
|
||||||
|
name: "Bootstrap Admin",
|
||||||
|
role: "ADMIN",
|
||||||
|
mustResetPassword: true,
|
||||||
|
isActive: false,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
email: true,
|
||||||
|
name: true,
|
||||||
|
role: true,
|
||||||
|
mustResetPassword: true,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// Extend Express Request type to include user
|
// Extend Express Request type to include user
|
||||||
declare global {
|
declare global {
|
||||||
@@ -87,6 +157,30 @@ export const requireAuth = async (
|
|||||||
res: Response,
|
res: Response,
|
||||||
next: NextFunction
|
next: NextFunction
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
|
// Single-user mode: authentication disabled -> treat all requests as the bootstrap user.
|
||||||
|
try {
|
||||||
|
const authEnabled = await getAuthEnabled();
|
||||||
|
if (!authEnabled) {
|
||||||
|
const user = await getBootstrapActingUser();
|
||||||
|
req.user = {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
name: user.name,
|
||||||
|
role: user.role,
|
||||||
|
mustResetPassword: user.mustResetPassword,
|
||||||
|
};
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error reading auth mode:", error);
|
||||||
|
res.status(500).json({
|
||||||
|
error: "Internal server error",
|
||||||
|
message: "Failed to read authentication mode",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const token = extractToken(req);
|
const token = extractToken(req);
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
@@ -159,6 +253,16 @@ export const optionalAuth = async (
|
|||||||
res: Response,
|
res: Response,
|
||||||
next: NextFunction
|
next: NextFunction
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const authEnabled = await getAuthEnabled();
|
||||||
|
if (!authEnabled) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error reading auth mode:", error);
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
const token = extractToken(req);
|
const token = extractToken(req);
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ interface ProtectedRouteProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children }) => {
|
export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children }) => {
|
||||||
const { isAuthenticated, loading } = useAuth();
|
const { isAuthenticated, loading, authEnabled, bootstrapRequired } = useAuth();
|
||||||
|
|
||||||
if (loading) {
|
if (loading || authEnabled === null) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center">
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
<div className="text-gray-600 dark:text-gray-400">Loading...</div>
|
<div className="text-gray-600 dark:text-gray-400">Loading...</div>
|
||||||
@@ -17,9 +17,18 @@ export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children }) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Single-user mode: auth disabled -> allow access.
|
||||||
|
if (!authEnabled) {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
|
// If auth is enabled but no admin exists yet, force bootstrap registration.
|
||||||
|
if (bootstrapRequired) {
|
||||||
|
return <Navigate to="/register" replace />;
|
||||||
|
}
|
||||||
return <Navigate to="/login" replace />;
|
return <Navigate to="/login" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
|||||||
onDrop
|
onDrop
|
||||||
}) => {
|
}) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { logout, user } = useAuth();
|
const { logout, user, authEnabled } = useAuth();
|
||||||
const [isCreating, setIsCreating] = useState(false);
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
const [newCollectionName, setNewCollectionName] = useState('');
|
const [newCollectionName, setNewCollectionName] = useState('');
|
||||||
const [editingId, setEditingId] = useState<string | null>(null);
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
@@ -286,18 +286,20 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
|||||||
<span className="min-w-0 flex-1 text-left">Trash</span>
|
<span className="min-w-0 flex-1 text-left">Trash</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
{authEnabled && (
|
||||||
onClick={() => navigate('/profile')}
|
<button
|
||||||
className={clsx(
|
onClick={() => navigate('/profile')}
|
||||||
"w-full flex items-center gap-3 px-3 py-2 text-sm font-bold rounded-xl transition-all duration-200 border-2 border-black dark:border-neutral-700 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)]",
|
className={clsx(
|
||||||
selectedCollectionId === 'PROFILE'
|
"w-full flex items-center gap-3 px-3 py-2 text-sm font-bold rounded-xl transition-all duration-200 border-2 border-black dark:border-neutral-700 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)]",
|
||||||
? "bg-indigo-50 dark:bg-neutral-800 text-indigo-900 dark:text-neutral-200 -translate-y-0.5"
|
selectedCollectionId === 'PROFILE'
|
||||||
: "bg-white dark:bg-neutral-900 text-slate-900 dark:text-neutral-200 hover:bg-slate-50 dark:hover:bg-neutral-800 hover:shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:hover:shadow-[4px_4px_0px_0px_rgba(255,255,255,0.2)] hover:-translate-y-0.5"
|
? "bg-indigo-50 dark:bg-neutral-800 text-indigo-900 dark:text-neutral-200 -translate-y-0.5"
|
||||||
)}
|
: "bg-white dark:bg-neutral-900 text-slate-900 dark:text-neutral-200 hover:bg-slate-50 dark:hover:bg-neutral-800 hover:shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:hover:shadow-[4px_4px_0px_0px_rgba(255,255,255,0.2)] hover:-translate-y-0.5"
|
||||||
>
|
)}
|
||||||
<User size={18} />
|
>
|
||||||
<span className="min-w-0 flex-1 text-left">Profile</span>
|
<User size={18} />
|
||||||
</button>
|
<span className="min-w-0 flex-1 text-left">Profile</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate('/settings')}
|
onClick={() => navigate('/settings')}
|
||||||
@@ -313,7 +315,8 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* User info and logout */}
|
{/* User info and logout */}
|
||||||
<div className="mt-auto pt-4 border-t-2 border-slate-200 dark:border-neutral-700">
|
{authEnabled && (
|
||||||
|
<div className="mt-auto pt-4 border-t-2 border-slate-200 dark:border-neutral-700">
|
||||||
{user && (
|
{user && (
|
||||||
<div className="px-3 py-2 text-xs text-slate-500 dark:text-neutral-500 mb-2">
|
<div className="px-3 py-2 text-xs text-slate-500 dark:text-neutral-500 mb-2">
|
||||||
<div className="font-semibold text-slate-700 dark:text-neutral-300">{user.name}</div>
|
<div className="font-semibold text-slate-700 dark:text-neutral-300">{user.name}</div>
|
||||||
@@ -327,7 +330,8 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
|||||||
<LogOut size={18} />
|
<LogOut size={18} />
|
||||||
<span className="min-w-0 flex-1 text-left">Logout</span>
|
<span className="min-w-0 flex-1 text-left">Logout</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -402,4 +406,3 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ interface User {
|
|||||||
interface AuthContextType {
|
interface AuthContextType {
|
||||||
user: User | null;
|
user: User | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
|
authEnabled: boolean | null;
|
||||||
|
bootstrapRequired: boolean;
|
||||||
login: (email: string, password: string) => Promise<void>;
|
login: (email: string, password: string) => Promise<void>;
|
||||||
register: (email: string, password: string, name: string) => Promise<void>;
|
register: (email: string, password: string, name: string) => Promise<void>;
|
||||||
logout: () => void;
|
logout: () => void;
|
||||||
@@ -32,12 +34,36 @@ const USER_KEY = 'excalidash-user';
|
|||||||
export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||||
const [user, setUser] = useState<User | null>(null);
|
const [user, setUser] = useState<User | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [authEnabled, setAuthEnabled] = useState<boolean | null>(null);
|
||||||
|
const [bootstrapRequired, setBootstrapRequired] = useState(false);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
// Load user from localStorage on mount
|
// Load user from localStorage on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadUser = async () => {
|
const loadUser = async () => {
|
||||||
try {
|
try {
|
||||||
|
// Determine auth mode first (single-user mode vs multi-user auth).
|
||||||
|
try {
|
||||||
|
const statusResponse = await axios.get(`${API_URL}/auth/status`);
|
||||||
|
const enabled =
|
||||||
|
typeof statusResponse.data?.authEnabled === "boolean"
|
||||||
|
? statusResponse.data.authEnabled
|
||||||
|
: typeof statusResponse.data?.enabled === "boolean"
|
||||||
|
? statusResponse.data.enabled
|
||||||
|
: true;
|
||||||
|
setAuthEnabled(enabled);
|
||||||
|
setBootstrapRequired(Boolean(statusResponse.data?.bootstrapRequired));
|
||||||
|
|
||||||
|
// In single-user mode, do not require login.
|
||||||
|
if (!enabled) {
|
||||||
|
setUser(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// If status fails, assume auth is enabled (safer default).
|
||||||
|
setAuthEnabled(true);
|
||||||
|
}
|
||||||
|
|
||||||
const storedUser = localStorage.getItem(USER_KEY);
|
const storedUser = localStorage.getItem(USER_KEY);
|
||||||
const storedToken = localStorage.getItem(TOKEN_KEY);
|
const storedToken = localStorage.getItem(TOKEN_KEY);
|
||||||
|
|
||||||
@@ -101,6 +127,9 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||||||
|
|
||||||
const login = async (email: string, password: string) => {
|
const login = async (email: string, password: string) => {
|
||||||
try {
|
try {
|
||||||
|
if (authEnabled === false) {
|
||||||
|
throw new Error("Authentication is disabled");
|
||||||
|
}
|
||||||
const response = await axios.post(`${API_URL}/auth/login`, {
|
const response = await axios.post(`${API_URL}/auth/login`, {
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
@@ -130,6 +159,9 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||||||
|
|
||||||
const register = async (email: string, password: string, name: string) => {
|
const register = async (email: string, password: string, name: string) => {
|
||||||
try {
|
try {
|
||||||
|
if (authEnabled === false) {
|
||||||
|
throw new Error("Authentication is disabled");
|
||||||
|
}
|
||||||
const response = await axios.post(`${API_URL}/auth/register`, {
|
const response = await axios.post(`${API_URL}/auth/register`, {
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
@@ -174,6 +206,8 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||||||
value={{
|
value={{
|
||||||
user,
|
user,
|
||||||
loading,
|
loading,
|
||||||
|
authEnabled,
|
||||||
|
bootstrapRequired,
|
||||||
login,
|
login,
|
||||||
register,
|
register,
|
||||||
logout,
|
logout,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { useNavigate, Link } from 'react-router-dom';
|
import { useNavigate, Link } from 'react-router-dom';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import { Logo } from '../components/Logo';
|
import { Logo } from '../components/Logo';
|
||||||
@@ -8,9 +8,24 @@ export const Login: React.FC = () => {
|
|||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const { login } = useAuth();
|
const { login, authEnabled, bootstrapRequired, isAuthenticated, loading: authLoading } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (authLoading || authEnabled === null) return;
|
||||||
|
if (!authEnabled) {
|
||||||
|
navigate('/', { replace: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (bootstrapRequired) {
|
||||||
|
navigate('/register', { replace: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isAuthenticated) {
|
||||||
|
navigate('/', { replace: true });
|
||||||
|
}
|
||||||
|
}, [authEnabled, authLoading, bootstrapRequired, isAuthenticated, navigate]);
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError('');
|
setError('');
|
||||||
@@ -108,4 +123,4 @@ export const Login: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import type { Collection } from '../types';
|
|||||||
import { User, Lock, Save, X, Shield } from 'lucide-react';
|
import { User, Lock, Save, X, Shield } from 'lucide-react';
|
||||||
|
|
||||||
export const Profile: React.FC = () => {
|
export const Profile: React.FC = () => {
|
||||||
const { user: authUser, logout } = useAuth();
|
const { user: authUser, logout, authEnabled } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const isAdmin = authUser?.role === 'ADMIN';
|
const isAdmin = authUser?.role === 'ADMIN';
|
||||||
const [collections, setCollections] = useState<Collection[]>([]);
|
const [collections, setCollections] = useState<Collection[]>([]);
|
||||||
@@ -28,6 +28,10 @@ export const Profile: React.FC = () => {
|
|||||||
const [showPasswordForm, setShowPasswordForm] = useState(false);
|
const [showPasswordForm, setShowPasswordForm] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (authEnabled === false) {
|
||||||
|
navigate('/settings', { replace: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
try {
|
try {
|
||||||
const collectionsData = await api.getCollections();
|
const collectionsData = await api.getCollections();
|
||||||
@@ -50,7 +54,7 @@ export const Profile: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
fetchData();
|
fetchData();
|
||||||
}, [authUser, isAdmin]);
|
}, [authEnabled, authUser, isAdmin, navigate]);
|
||||||
|
|
||||||
const handleToggleRegistration = async () => {
|
const handleToggleRegistration = async () => {
|
||||||
if (!isAdmin || registrationEnabled === null) return;
|
if (!isAdmin || registrationEnabled === null) return;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { useNavigate, Link } from 'react-router-dom';
|
import { useNavigate, Link } from 'react-router-dom';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import { Logo } from '../components/Logo';
|
import { Logo } from '../components/Logo';
|
||||||
@@ -9,9 +9,20 @@ export const Register: React.FC = () => {
|
|||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const { register } = useAuth();
|
const { register, authEnabled, bootstrapRequired, isAuthenticated, loading: authLoading } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (authLoading || authEnabled === null) return;
|
||||||
|
if (!authEnabled) {
|
||||||
|
navigate('/', { replace: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isAuthenticated) {
|
||||||
|
navigate('/', { replace: true });
|
||||||
|
}
|
||||||
|
}, [authEnabled, authLoading, isAuthenticated, navigate]);
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError('');
|
setError('');
|
||||||
@@ -40,16 +51,22 @@ export const Register: React.FC = () => {
|
|||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<Logo className="mx-auto h-12 w-auto" />
|
<Logo className="mx-auto h-12 w-auto" />
|
||||||
<h2 className="mt-6 text-3xl font-extrabold text-gray-900 dark:text-white">
|
<h2 className="mt-6 text-3xl font-extrabold text-gray-900 dark:text-white">
|
||||||
Create your account
|
{bootstrapRequired ? 'Set up admin account' : 'Create your account'}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||||
Or{' '}
|
{bootstrapRequired ? (
|
||||||
<Link
|
<span>This will enable multi-user access for this ExcaliDash instance.</span>
|
||||||
to="/login"
|
) : (
|
||||||
className="font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400"
|
<>
|
||||||
>
|
Or{' '}
|
||||||
sign in to your existing account
|
<Link
|
||||||
</Link>
|
to="/login"
|
||||||
|
className="font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400"
|
||||||
|
>
|
||||||
|
sign in to your existing account
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
||||||
@@ -123,4 +140,4 @@ export const Register: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,15 +7,19 @@ import { Database, FileJson, Upload, Moon, Sun, Info, HardDrive } from 'lucide-r
|
|||||||
import { ConfirmModal } from '../components/ConfirmModal';
|
import { ConfirmModal } from '../components/ConfirmModal';
|
||||||
import { importDrawings } from '../utils/importUtils';
|
import { importDrawings } from '../utils/importUtils';
|
||||||
import { useTheme } from '../context/ThemeContext';
|
import { useTheme } from '../context/ThemeContext';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
|
||||||
export const Settings: React.FC = () => {
|
export const Settings: React.FC = () => {
|
||||||
const [collections, setCollections] = useState<Collection[]>([]);
|
const [collections, setCollections] = useState<Collection[]>([]);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { theme, toggleTheme } = useTheme();
|
const { theme, toggleTheme } = useTheme();
|
||||||
|
const { authEnabled, user } = useAuth();
|
||||||
|
|
||||||
const [importConfirmation, setImportConfirmation] = useState<{ isOpen: boolean; file: File | null }>({ isOpen: false, file: null });
|
const [importConfirmation, setImportConfirmation] = useState<{ isOpen: boolean; file: File | null }>({ isOpen: false, file: null });
|
||||||
const [importError, setImportError] = useState<{ isOpen: boolean; message: string }>({ isOpen: false, message: '' });
|
const [importError, setImportError] = useState<{ isOpen: boolean; message: string }>({ isOpen: false, message: '' });
|
||||||
const [importSuccess, setImportSuccess] = useState(false);
|
const [importSuccess, setImportSuccess] = useState(false);
|
||||||
|
const [authToggleLoading, setAuthToggleLoading] = useState(false);
|
||||||
|
const [authToggleError, setAuthToggleError] = useState<string | null>(null);
|
||||||
|
|
||||||
const appVersion = import.meta.env.VITE_APP_VERSION || 'Unknown version';
|
const appVersion = import.meta.env.VITE_APP_VERSION || 'Unknown version';
|
||||||
const buildLabel = import.meta.env.VITE_APP_BUILD_LABEL;
|
const buildLabel = import.meta.env.VITE_APP_BUILD_LABEL;
|
||||||
@@ -32,6 +36,39 @@ export const Settings: React.FC = () => {
|
|||||||
fetchCollections();
|
fetchCollections();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const toggleAuthEnabled = async () => {
|
||||||
|
if (authEnabled === null) return;
|
||||||
|
setAuthToggleLoading(true);
|
||||||
|
setAuthToggleError(null);
|
||||||
|
try {
|
||||||
|
const next = !authEnabled;
|
||||||
|
const response = await api.api.post<{ authEnabled: boolean; bootstrapRequired?: boolean }>(
|
||||||
|
'/auth/auth-enabled',
|
||||||
|
{ enabled: next },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.data.authEnabled) {
|
||||||
|
// Auth enabled -> prompt admin bootstrap via register.
|
||||||
|
window.location.href = '/register';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auth disabled -> reload to drop auth gating.
|
||||||
|
window.location.reload();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
let message = 'Failed to update authentication setting';
|
||||||
|
if (api.isAxiosError(err)) {
|
||||||
|
message =
|
||||||
|
err.response?.data?.message ||
|
||||||
|
err.response?.data?.error ||
|
||||||
|
message;
|
||||||
|
}
|
||||||
|
setAuthToggleError(message);
|
||||||
|
} finally {
|
||||||
|
setAuthToggleLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleCreateCollection = async (name: string) => {
|
const handleCreateCollection = async (name: string) => {
|
||||||
await api.createCollection(name);
|
await api.createCollection(name);
|
||||||
const newCollections = await api.getCollections();
|
const newCollections = await api.getCollections();
|
||||||
@@ -69,7 +106,37 @@ export const Settings: React.FC = () => {
|
|||||||
Settings
|
Settings
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
|
{authToggleError && (
|
||||||
|
<div className="mb-6 p-4 bg-red-50 dark:bg-red-900/20 border-2 border-red-200 dark:border-red-800 rounded-xl">
|
||||||
|
<p className="text-red-800 dark:text-red-200 font-medium">{authToggleError}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
<button
|
||||||
|
onClick={toggleAuthEnabled}
|
||||||
|
disabled={authEnabled === null || authToggleLoading || (authEnabled === true && user?.role !== 'ADMIN')}
|
||||||
|
className="flex flex-col items-center justify-center gap-4 p-8 bg-white dark:bg-neutral-900 border-2 border-black dark:border-neutral-700 rounded-2xl shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:shadow-[4px_4px_0px_0px_rgba(255,255,255,0.2)] hover:shadow-[6px_6px_0px_0px_rgba(0,0,0,1)] dark:hover:shadow-[6px_6px_0px_0px_rgba(255,255,255,0.2)] hover:-translate-y-1 transition-all duration-200 group disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] disabled:hover:translate-y-0"
|
||||||
|
>
|
||||||
|
<div className="w-16 h-16 bg-slate-50 dark:bg-neutral-800 rounded-2xl flex items-center justify-center border-2 border-slate-200 dark:border-neutral-700 group-hover:border-slate-300 dark:group-hover:border-neutral-600 transition-colors">
|
||||||
|
<Info size={32} className="text-slate-700 dark:text-neutral-300" />
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<h3 className="text-xl font-bold text-slate-900 dark:text-white mb-1">
|
||||||
|
{authEnabled ? 'Authentication: On' : 'Authentication: Off'}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-slate-500 dark:text-neutral-400 font-medium">
|
||||||
|
{authEnabled
|
||||||
|
? user?.role === 'ADMIN'
|
||||||
|
? (authToggleLoading ? 'Disabling…' : 'Disable multi-user login')
|
||||||
|
: 'Only admins can disable'
|
||||||
|
: authToggleLoading
|
||||||
|
? 'Enabling…'
|
||||||
|
: 'Enable multi-user login'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={toggleTheme}
|
onClick={toggleTheme}
|
||||||
className="flex flex-col items-center justify-center gap-4 p-8 bg-white dark:bg-neutral-900 border-2 border-black dark:border-neutral-700 rounded-2xl shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:shadow-[4px_4px_0px_0px_rgba(255,255,255,0.2)] hover:shadow-[6px_6px_0px_0px_rgba(0,0,0,1)] dark:hover:shadow-[6px_6px_0px_0px_rgba(255,255,255,0.2)] hover:-translate-y-1 transition-all duration-200 group"
|
className="flex flex-col items-center justify-center gap-4 p-8 bg-white dark:bg-neutral-900 border-2 border-black dark:border-neutral-700 rounded-2xl shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:shadow-[4px_4px_0px_0px_rgba(255,255,255,0.2)] hover:shadow-[6px_6px_0px_0px_rgba(0,0,0,1)] dark:hover:shadow-[6px_6px_0px_0px_rgba(255,255,255,0.2)] hover:-translate-y-1 transition-all duration-200 group"
|
||||||
|
|||||||
Reference in New Issue
Block a user