@thunder-stack/create-thunder-app 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.js +229 -0
- package/package.json +26 -0
- package/postpublish.js +14 -0
- package/prepublish.js +68 -0
- package/template/.env.example +13 -0
- package/template/README.md +1 -0
- package/template/THUNDER_STACK.md +120 -0
- package/template/client/assets/logo.svg +36 -0
- package/template/client/expo/App.test.tsx +20 -0
- package/template/client/expo/App.tsx +16 -0
- package/template/client/expo/app.json +30 -0
- package/template/client/expo/babel.config.js +12 -0
- package/template/client/expo/global.css +3 -0
- package/template/client/expo/jest.config.js +7 -0
- package/template/client/expo/metro.config.js +21 -0
- package/template/client/expo/package.json +33 -0
- package/template/client/expo/tsconfig.json +6 -0
- package/template/client/next/app/globals.css +14 -0
- package/template/client/next/app/layout.tsx +28 -0
- package/template/client/next/app/page.test.tsx +13 -0
- package/template/client/next/app/page.tsx +10 -0
- package/template/client/next/next-env.d.ts +5 -0
- package/template/client/next/next.config.js +21 -0
- package/template/client/next/package.json +39 -0
- package/template/client/next/playwright.config.ts +17 -0
- package/template/client/next/postcss.config.js +6 -0
- package/template/client/next/tsconfig.json +30 -0
- package/template/client/next/vitest.config.ts +11 -0
- package/template/client/next/vitest.setup.ts +1 -0
- package/template/client/shared/index.ts +2 -0
- package/template/client/shared/package.json +24 -0
- package/template/client/shared/src/components/Button.tsx +38 -0
- package/template/client/shared/src/components/Card.tsx +17 -0
- package/template/client/shared/src/features/auth/login.tsx +139 -0
- package/template/client/shared/src/features/dashboard/index.tsx +232 -0
- package/template/client/shared/src/features/home/screen.tsx +212 -0
- package/template/client/shared/src/features/management/roles.tsx +356 -0
- package/template/client/shared/src/features/management/users.tsx +245 -0
- package/template/client/shared/src/nativewind-env.d.ts +1 -0
- package/template/client/shared/src/utils/auth.ts +21 -0
- package/template/client/shared/tailwind.config.js +19 -0
- package/template/client/shared/tsconfig.json +9 -0
- package/template/package.json +47 -0
- package/template/packages/tsconfig/base.json +20 -0
- package/template/packages/tsconfig/package.json +11 -0
- package/template/pnpm-workspace.yaml +4 -0
- package/template/scripts/clean.js +56 -0
- package/template/server/db/drizzle.config.ts +10 -0
- package/template/server/db/migrations/0000_loving_mindworm.sql +86 -0
- package/template/server/db/migrations/meta/0000_snapshot.json +549 -0
- package/template/server/db/migrations/meta/_journal.json +13 -0
- package/template/server/db/package.json +28 -0
- package/template/server/db/src/index.ts +16 -0
- package/template/server/db/src/migrate.ts +28 -0
- package/template/server/db/src/schema/auth.ts +73 -0
- package/template/server/db/src/schema/index.ts +2 -0
- package/template/server/db/src/schema/rbac.ts +72 -0
- package/template/server/db/src/schema.ts +1 -0
- package/template/server/db/src/seed.ts +204 -0
- package/template/server/db/src/services/rbac.ts +144 -0
- package/template/server/db/src/services/user.ts +34 -0
- package/template/server/db/src/types/index.ts +34 -0
- package/template/server/db/tsconfig.json +8 -0
- package/template/server/hono/package.json +22 -0
- package/template/server/hono/src/auth.ts +29 -0
- package/template/server/hono/src/index.ts +215 -0
- package/template/server/hono/tsconfig.json +8 -0
- package/template/server/hono/wrangler.toml +12 -0
- package/template/turbo.json +68 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import "./globals.css";
|
|
2
|
+
import type { Metadata } from "next";
|
|
3
|
+
|
|
4
|
+
export const metadata: Metadata = {
|
|
5
|
+
title: "THUNDER Stack • Cross-Platform Hub",
|
|
6
|
+
description: "Universal web and mobile sign-in platform built with Next.js, Expo, and Solito.",
|
|
7
|
+
icons: {
|
|
8
|
+
icon: "/logo.svg",
|
|
9
|
+
shortcut: "/logo.svg",
|
|
10
|
+
apple: "/logo.svg",
|
|
11
|
+
},
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export default function RootLayout({
|
|
15
|
+
children,
|
|
16
|
+
}: {
|
|
17
|
+
children: React.ReactNode;
|
|
18
|
+
}) {
|
|
19
|
+
return (
|
|
20
|
+
<html lang="en" className="h-full bg-slate-950">
|
|
21
|
+
<body className="h-full font-sans antialiased text-slate-200">
|
|
22
|
+
<main id="app-root" className="h-full">
|
|
23
|
+
{children}
|
|
24
|
+
</main>
|
|
25
|
+
</body>
|
|
26
|
+
</html>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import Page from "./page";
|
|
3
|
+
import { expect, test, vi } from "vitest";
|
|
4
|
+
|
|
5
|
+
// Mock the shared Solito screen to isolate wrapper test
|
|
6
|
+
vi.mock("@thunder/app", () => ({
|
|
7
|
+
HomeScreen: () => <div>Thunder Test</div>,
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
test("renders the Next.js page wrapper with the shared home screen", () => {
|
|
11
|
+
render(<Page />);
|
|
12
|
+
expect(screen.getByText("Thunder Test")).toBeInTheDocument();
|
|
13
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** @type {import('next').NextConfig} */
|
|
2
|
+
const nextConfig = {
|
|
3
|
+
transpilePackages: [
|
|
4
|
+
"react-native",
|
|
5
|
+
"react-native-web",
|
|
6
|
+
"solito",
|
|
7
|
+
"@thunder/app",
|
|
8
|
+
"moti",
|
|
9
|
+
"react-native-reanimated"
|
|
10
|
+
],
|
|
11
|
+
webpack: (config) => {
|
|
12
|
+
config.resolve.alias = {
|
|
13
|
+
...(config.resolve.alias || {}),
|
|
14
|
+
// Transform direct react-native imports to react-native-web
|
|
15
|
+
"react-native$": "react-native-web",
|
|
16
|
+
};
|
|
17
|
+
return config;
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
module.exports = nextConfig;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "next-web-app",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"scripts": {
|
|
6
|
+
"dev": "next dev",
|
|
7
|
+
"build": "next build",
|
|
8
|
+
"start": "next start",
|
|
9
|
+
"pages:build": "opennextjs-cloudflare build",
|
|
10
|
+
"test:unit": "vitest run",
|
|
11
|
+
"test:e2e": "playwright test"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@thunder/app": "workspace:*",
|
|
15
|
+
"moti": "^0.28.1",
|
|
16
|
+
"next": "^14.2.35",
|
|
17
|
+
"react": "^18.3.1",
|
|
18
|
+
"react-dom": "^18.3.1",
|
|
19
|
+
"react-native-reanimated": "~3.10.1",
|
|
20
|
+
"react-native-web": "^0.19.13"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@opennextjs/cloudflare": "^1.20.1",
|
|
24
|
+
"@thunder/tsconfig": "workspace:*",
|
|
25
|
+
"@playwright/test": "^1.61.1",
|
|
26
|
+
"@testing-library/jest-dom": "^6.9.1",
|
|
27
|
+
"@testing-library/react": "^14.3.1",
|
|
28
|
+
"@types/node": "^20.19.43",
|
|
29
|
+
"@types/react": "^18.3.31",
|
|
30
|
+
"@types/react-dom": "^18.3.7",
|
|
31
|
+
"@vitejs/plugin-react": "^4.7.0",
|
|
32
|
+
"autoprefixer": "^10.5.2",
|
|
33
|
+
"jsdom": "^26.1.0",
|
|
34
|
+
"postcss": "^8.5.16",
|
|
35
|
+
"tailwindcss": "^3.4.19",
|
|
36
|
+
"typescript": "^5.9.3",
|
|
37
|
+
"vitest": "^3.2.7"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { defineConfig, devices } from "@playwright/test";
|
|
2
|
+
|
|
3
|
+
export default defineConfig({
|
|
4
|
+
testDir: "./e2e",
|
|
5
|
+
fullyParallel: true,
|
|
6
|
+
reporter: "html",
|
|
7
|
+
use: {
|
|
8
|
+
baseURL: "http://localhost:3000",
|
|
9
|
+
trace: "on-first-retry",
|
|
10
|
+
},
|
|
11
|
+
projects: [
|
|
12
|
+
{
|
|
13
|
+
name: "chromium",
|
|
14
|
+
use: { ...devices["Desktop Chrome"] },
|
|
15
|
+
},
|
|
16
|
+
],
|
|
17
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "@thunder/tsconfig/base.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"lib": [
|
|
5
|
+
"dom",
|
|
6
|
+
"dom.iterable",
|
|
7
|
+
"esnext"
|
|
8
|
+
],
|
|
9
|
+
"allowJs": true,
|
|
10
|
+
"noEmit": true,
|
|
11
|
+
"module": "esnext",
|
|
12
|
+
"moduleResolution": "bundler",
|
|
13
|
+
"jsx": "preserve",
|
|
14
|
+
"plugins": [
|
|
15
|
+
{
|
|
16
|
+
"name": "next"
|
|
17
|
+
}
|
|
18
|
+
],
|
|
19
|
+
"incremental": true
|
|
20
|
+
},
|
|
21
|
+
"include": [
|
|
22
|
+
"next-env.d.ts",
|
|
23
|
+
"**/*.ts",
|
|
24
|
+
"**/*.tsx",
|
|
25
|
+
".next/types/**/*.ts"
|
|
26
|
+
],
|
|
27
|
+
"exclude": [
|
|
28
|
+
"node_modules"
|
|
29
|
+
]
|
|
30
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import "@testing-library/jest-dom";
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@thunder/app",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"main": "index.ts",
|
|
6
|
+
"dependencies": {
|
|
7
|
+
"@react-native-async-storage/async-storage": "^1.24.0",
|
|
8
|
+
"better-auth": "^1.6.23",
|
|
9
|
+
"moti": "^0.28.1",
|
|
10
|
+
"nativewind": "^4.2.6",
|
|
11
|
+
"react-native": "*",
|
|
12
|
+
"react-native-reanimated": "~3.10.1",
|
|
13
|
+
"solito": "^4.4.1"
|
|
14
|
+
},
|
|
15
|
+
"peerDependencies": {
|
|
16
|
+
"react": "*"
|
|
17
|
+
},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"@thunder/tsconfig": "workspace:*",
|
|
20
|
+
"@types/node": "^20.19.43",
|
|
21
|
+
"@types/react": "^18.3.31",
|
|
22
|
+
"react": "^18.3.1"
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Pressable, Text, ActivityIndicator } from "react-native";
|
|
3
|
+
|
|
4
|
+
interface ButtonProps {
|
|
5
|
+
title: string;
|
|
6
|
+
onPress: () => void;
|
|
7
|
+
loading?: boolean;
|
|
8
|
+
variant?: "primary" | "secondary" | "danger";
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function Button({ title, onPress, loading = false, variant = "primary" }: ButtonProps) {
|
|
12
|
+
let containerStyle = "px-6 py-4 rounded-2xl flex-row items-center justify-center shadow-lg ";
|
|
13
|
+
let textStyle = "font-bold text-base tracking-wide ";
|
|
14
|
+
|
|
15
|
+
if (variant === "primary") {
|
|
16
|
+
containerStyle += "bg-blue-600 active:bg-blue-700 shadow-blue-500/20";
|
|
17
|
+
textStyle += "text-white";
|
|
18
|
+
} else if (variant === "secondary") {
|
|
19
|
+
containerStyle += "bg-slate-800 border border-slate-700 active:bg-slate-750 shadow-black/40";
|
|
20
|
+
textStyle += "text-slate-200";
|
|
21
|
+
} else {
|
|
22
|
+
containerStyle += "bg-rose-600 active:bg-rose-700 shadow-rose-500/20";
|
|
23
|
+
textStyle += "text-white";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<Pressable
|
|
28
|
+
className={containerStyle}
|
|
29
|
+
onPress={loading ? undefined : onPress}
|
|
30
|
+
disabled={loading}
|
|
31
|
+
>
|
|
32
|
+
{loading ? (
|
|
33
|
+
<ActivityIndicator size="small" color="#ffffff" className="mr-2" />
|
|
34
|
+
) : null}
|
|
35
|
+
<Text className={textStyle}>{title}</Text>
|
|
36
|
+
</Pressable>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { View } from "react-native";
|
|
3
|
+
|
|
4
|
+
interface CardProps {
|
|
5
|
+
children: React.ReactNode;
|
|
6
|
+
className?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function Card({ children, className = "" }: CardProps) {
|
|
10
|
+
return (
|
|
11
|
+
<View
|
|
12
|
+
className={`bg-slate-900/60 border border-slate-800/80 p-8 rounded-3xl w-full max-w-md shadow-2xl backdrop-blur-md ${className}`}
|
|
13
|
+
>
|
|
14
|
+
{children}
|
|
15
|
+
</View>
|
|
16
|
+
);
|
|
17
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import React, { useState } from "react";
|
|
2
|
+
import { View, Text, TextInput, ActivityIndicator, Platform, TouchableOpacity } from "react-native";
|
|
3
|
+
import { Button } from "../../components/Button";
|
|
4
|
+
import { Card } from "../../components/Card";
|
|
5
|
+
import { authClient } from "../../utils/auth";
|
|
6
|
+
|
|
7
|
+
interface LoginProps {
|
|
8
|
+
onSuccess: () => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function LoginScreen({ onSuccess }: LoginProps) {
|
|
12
|
+
const [email, setEmail] = useState("");
|
|
13
|
+
const [password, setPassword] = useState("");
|
|
14
|
+
const [loading, setLoading] = useState(false);
|
|
15
|
+
const [error, setError] = useState("");
|
|
16
|
+
|
|
17
|
+
const handleEmailSignIn = async () => {
|
|
18
|
+
if (!email || !password) {
|
|
19
|
+
setError("Please enter both email and password.");
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
setLoading(true);
|
|
24
|
+
setError("");
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
const response = await authClient.signIn.email({
|
|
28
|
+
email,
|
|
29
|
+
password,
|
|
30
|
+
});
|
|
31
|
+
if (response?.error) {
|
|
32
|
+
setError(response.error.message || "Invalid email or password.");
|
|
33
|
+
} else {
|
|
34
|
+
onSuccess();
|
|
35
|
+
}
|
|
36
|
+
} catch (err: any) {
|
|
37
|
+
setError(err?.message || "An unexpected error occurred during login.");
|
|
38
|
+
} finally {
|
|
39
|
+
setLoading(false);
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const handleGoogleSignIn = async () => {
|
|
44
|
+
setLoading(true);
|
|
45
|
+
setError("");
|
|
46
|
+
try {
|
|
47
|
+
const callbackURL = Platform.select({
|
|
48
|
+
web: typeof window !== "undefined" ? window.location.origin : "http://localhost:3000",
|
|
49
|
+
default: "exp://localhost:8081",
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
await authClient.signIn.social({
|
|
53
|
+
provider: "google",
|
|
54
|
+
callbackURL,
|
|
55
|
+
});
|
|
56
|
+
} catch (err: any) {
|
|
57
|
+
setError(err?.message || "Google Authentication failed.");
|
|
58
|
+
setLoading(false);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<View className="w-full max-w-md px-4">
|
|
64
|
+
<Card className="border-slate-800 bg-slate-900/60 backdrop-blur-xl p-8 rounded-3xl shadow-2xl border">
|
|
65
|
+
<Text className="text-white text-3xl font-black mb-2 text-center tracking-tight">
|
|
66
|
+
Welcome Back
|
|
67
|
+
</Text>
|
|
68
|
+
<Text className="text-slate-400 text-sm font-medium mb-8 text-center">
|
|
69
|
+
Secure sign-in for Web and Mobile
|
|
70
|
+
</Text>
|
|
71
|
+
|
|
72
|
+
{error ? (
|
|
73
|
+
<View className="mb-6 p-4 bg-red-950/40 border border-red-500/20 rounded-xl">
|
|
74
|
+
<Text className="text-red-400 text-xs font-semibold text-center">{error}</Text>
|
|
75
|
+
</View>
|
|
76
|
+
) : null}
|
|
77
|
+
|
|
78
|
+
<View className="gap-5">
|
|
79
|
+
<View>
|
|
80
|
+
<Text className="text-slate-400 text-xs font-bold uppercase tracking-wider mb-2">
|
|
81
|
+
Email Address
|
|
82
|
+
</Text>
|
|
83
|
+
<TextInput
|
|
84
|
+
className="w-full bg-slate-950/60 border border-slate-800 text-white rounded-xl px-4 py-3 text-sm font-medium focus:border-blue-500"
|
|
85
|
+
placeholder="e.g. admin@thunderstack.dev"
|
|
86
|
+
placeholderTextColor="#64748b"
|
|
87
|
+
value={email}
|
|
88
|
+
onChangeText={setEmail}
|
|
89
|
+
autoCapitalize="none"
|
|
90
|
+
keyboardType="email-address"
|
|
91
|
+
/>
|
|
92
|
+
</View>
|
|
93
|
+
|
|
94
|
+
<View className="mb-2">
|
|
95
|
+
<Text className="text-slate-400 text-xs font-bold uppercase tracking-wider mb-2">
|
|
96
|
+
Password
|
|
97
|
+
</Text>
|
|
98
|
+
<TextInput
|
|
99
|
+
className="w-full bg-slate-950/60 border border-slate-800 text-white rounded-xl px-4 py-3 text-sm font-medium focus:border-blue-500"
|
|
100
|
+
placeholder="••••••••"
|
|
101
|
+
placeholderTextColor="#64748b"
|
|
102
|
+
secureTextEntry
|
|
103
|
+
value={password}
|
|
104
|
+
onChangeText={setPassword}
|
|
105
|
+
autoCapitalize="none"
|
|
106
|
+
/>
|
|
107
|
+
</View>
|
|
108
|
+
|
|
109
|
+
{loading ? (
|
|
110
|
+
<ActivityIndicator size="small" color="#3b82f6" className="my-2" />
|
|
111
|
+
) : (
|
|
112
|
+
<Button title="Sign In" onPress={handleEmailSignIn} variant="primary" />
|
|
113
|
+
)}
|
|
114
|
+
|
|
115
|
+
<View className="flex-row items-center my-4">
|
|
116
|
+
<View className="flex-1 h-[1px] bg-slate-800" />
|
|
117
|
+
<Text className="text-slate-500 text-xs font-bold uppercase mx-4">or</Text>
|
|
118
|
+
<View className="flex-1 h-[1px] bg-slate-800" />
|
|
119
|
+
</View>
|
|
120
|
+
|
|
121
|
+
<Button
|
|
122
|
+
title="Continue with Google"
|
|
123
|
+
onPress={handleGoogleSignIn}
|
|
124
|
+
variant="secondary"
|
|
125
|
+
/>
|
|
126
|
+
</View>
|
|
127
|
+
|
|
128
|
+
{/* Admin Creds Helper Info */}
|
|
129
|
+
<View className="mt-8 pt-6 border-t border-slate-800/60">
|
|
130
|
+
<Text className="text-slate-500 text-[10px] text-center font-medium leading-4">
|
|
131
|
+
Admin Account Seeded:{"\n"}
|
|
132
|
+
<Text className="text-blue-500 font-mono select-all">admin@thunderstack.dev</Text> /{" "}
|
|
133
|
+
<Text className="text-blue-500 font-mono select-all">AdminPassword123!</Text>
|
|
134
|
+
</Text>
|
|
135
|
+
</View>
|
|
136
|
+
</Card>
|
|
137
|
+
</View>
|
|
138
|
+
);
|
|
139
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import React, { useEffect, useState } from "react";
|
|
2
|
+
import { View as MotiView } from "moti";
|
|
3
|
+
import { View, Text, ActivityIndicator, ScrollView, RefreshControl, Platform } from "react-native";
|
|
4
|
+
import { Card } from "../../components/Card";
|
|
5
|
+
import { Button } from "../../components/Button";
|
|
6
|
+
import { authClient } from "../../utils/auth";
|
|
7
|
+
|
|
8
|
+
interface DashboardProps {
|
|
9
|
+
user: any;
|
|
10
|
+
roles: string[];
|
|
11
|
+
permissions: string[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function SkeletonCard({ delay }: { delay: number }) {
|
|
15
|
+
return (
|
|
16
|
+
<MotiView
|
|
17
|
+
className="flex-1 min-w-[200px]"
|
|
18
|
+
from={{ opacity: 0.3, scale: 0.95 }}
|
|
19
|
+
animate={{ opacity: 0.7, scale: 1 }}
|
|
20
|
+
transition={{
|
|
21
|
+
type: "timing",
|
|
22
|
+
duration: 900,
|
|
23
|
+
loop: true,
|
|
24
|
+
delay,
|
|
25
|
+
}}
|
|
26
|
+
>
|
|
27
|
+
<Card className="bg-slate-900/40 border-slate-800 p-6 rounded-2xl border min-h-[110px]">
|
|
28
|
+
<View className="h-4 w-24 bg-slate-800 rounded mb-4" />
|
|
29
|
+
<View className="h-8 w-12 bg-slate-800 rounded" />
|
|
30
|
+
</Card>
|
|
31
|
+
</MotiView>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function DashboardView({ user, roles, permissions }: DashboardProps) {
|
|
36
|
+
const [stats, setStats] = useState<{ users: number; roles: number; permissions: number } | null>(null);
|
|
37
|
+
const [loading, setLoading] = useState(true);
|
|
38
|
+
const [refreshing, setRefreshing] = useState(false);
|
|
39
|
+
|
|
40
|
+
const fetchStats = async () => {
|
|
41
|
+
try {
|
|
42
|
+
const res = await fetch(`${authClient.baseURL}/api/dashboard/stats`, {
|
|
43
|
+
headers: {
|
|
44
|
+
"Content-Type": "application/json",
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
const data = await res.json();
|
|
48
|
+
if (!data.error) {
|
|
49
|
+
setStats(data);
|
|
50
|
+
}
|
|
51
|
+
} catch (err) {
|
|
52
|
+
console.error("Failed to load dashboard stats:", err);
|
|
53
|
+
} finally {
|
|
54
|
+
setLoading(false);
|
|
55
|
+
setRefreshing(false);
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
fetchStats();
|
|
61
|
+
}, []);
|
|
62
|
+
|
|
63
|
+
const onRefresh = () => {
|
|
64
|
+
setRefreshing(true);
|
|
65
|
+
fetchStats();
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<ScrollView
|
|
70
|
+
className="flex-1"
|
|
71
|
+
refreshControl={
|
|
72
|
+
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor="#3b82f6" />
|
|
73
|
+
}
|
|
74
|
+
>
|
|
75
|
+
<View className="p-6 gap-8">
|
|
76
|
+
{/* Welcome Banner */}
|
|
77
|
+
<MotiView
|
|
78
|
+
from={{ opacity: 0, translateY: -20 }}
|
|
79
|
+
animate={{ opacity: 1, translateY: 0 }}
|
|
80
|
+
transition={{ type: "timing", duration: 400 }}
|
|
81
|
+
>
|
|
82
|
+
<Text className="text-slate-400 text-xs font-bold uppercase tracking-widest mb-1">
|
|
83
|
+
Overview
|
|
84
|
+
</Text>
|
|
85
|
+
<Text className="text-white text-3xl font-black tracking-tight">
|
|
86
|
+
Welcome back, <Text className="text-yellow-500">{user.name}</Text>
|
|
87
|
+
</Text>
|
|
88
|
+
</MotiView>
|
|
89
|
+
|
|
90
|
+
{/* Stats Grid */}
|
|
91
|
+
<View className="flex-row flex-wrap gap-4">
|
|
92
|
+
{loading ? (
|
|
93
|
+
<>
|
|
94
|
+
<SkeletonCard delay={0} />
|
|
95
|
+
<SkeletonCard delay={150} />
|
|
96
|
+
<SkeletonCard delay={300} />
|
|
97
|
+
</>
|
|
98
|
+
) : (
|
|
99
|
+
<>
|
|
100
|
+
<MotiView
|
|
101
|
+
className="flex-1 min-w-[200px]"
|
|
102
|
+
from={{ opacity: 0, scale: 0.95 }}
|
|
103
|
+
animate={{ opacity: 1, scale: 1 }}
|
|
104
|
+
transition={{ type: "timing", duration: 500, delay: 100 }}
|
|
105
|
+
>
|
|
106
|
+
<Card className="bg-slate-900/40 border-slate-800 p-6 rounded-2xl border relative overflow-hidden">
|
|
107
|
+
<View className="absolute -right-4 -bottom-4 w-24 h-24 bg-blue-500/10 rounded-full blur-xl" />
|
|
108
|
+
<Text className="text-slate-400 text-xs font-bold uppercase tracking-wider mb-2">
|
|
109
|
+
Total Users
|
|
110
|
+
</Text>
|
|
111
|
+
<Text className="text-white text-4xl font-extrabold tracking-tight">
|
|
112
|
+
{stats?.users ?? 0}
|
|
113
|
+
</Text>
|
|
114
|
+
</Card>
|
|
115
|
+
</MotiView>
|
|
116
|
+
|
|
117
|
+
<MotiView
|
|
118
|
+
className="flex-1 min-w-[200px]"
|
|
119
|
+
from={{ opacity: 0, scale: 0.95 }}
|
|
120
|
+
animate={{ opacity: 1, scale: 1 }}
|
|
121
|
+
transition={{ type: "timing", duration: 500, delay: 200 }}
|
|
122
|
+
>
|
|
123
|
+
<Card className="bg-slate-900/40 border-slate-800 p-6 rounded-2xl border relative overflow-hidden">
|
|
124
|
+
<View className="absolute -right-4 -bottom-4 w-24 h-24 bg-indigo-500/10 rounded-full blur-xl" />
|
|
125
|
+
<Text className="text-slate-400 text-xs font-bold uppercase tracking-wider mb-2">
|
|
126
|
+
Active Roles
|
|
127
|
+
</Text>
|
|
128
|
+
<Text className="text-white text-4xl font-extrabold tracking-tight">
|
|
129
|
+
{stats?.roles ?? 0}
|
|
130
|
+
</Text>
|
|
131
|
+
</Card>
|
|
132
|
+
</MotiView>
|
|
133
|
+
|
|
134
|
+
<MotiView
|
|
135
|
+
className="flex-1 min-w-[200px]"
|
|
136
|
+
from={{ opacity: 0, scale: 0.95 }}
|
|
137
|
+
animate={{ opacity: 1, scale: 1 }}
|
|
138
|
+
transition={{ type: "timing", duration: 500, delay: 300 }}
|
|
139
|
+
>
|
|
140
|
+
<Card className="bg-slate-900/40 border-slate-800 p-6 rounded-2xl border relative overflow-hidden">
|
|
141
|
+
<View className="absolute -right-4 -bottom-4 w-24 h-24 bg-purple-500/10 rounded-full blur-xl" />
|
|
142
|
+
<Text className="text-slate-400 text-xs font-bold uppercase tracking-wider mb-2">
|
|
143
|
+
Permissions Enabled
|
|
144
|
+
</Text>
|
|
145
|
+
<Text className="text-white text-4xl font-extrabold tracking-tight">
|
|
146
|
+
{stats?.permissions ?? 0}
|
|
147
|
+
</Text>
|
|
148
|
+
</Card>
|
|
149
|
+
</MotiView>
|
|
150
|
+
</>
|
|
151
|
+
)}
|
|
152
|
+
</View>
|
|
153
|
+
|
|
154
|
+
{/* User Roles & Permissions Info */}
|
|
155
|
+
<MotiView
|
|
156
|
+
from={{ opacity: 0, translateY: 20 }}
|
|
157
|
+
animate={{ opacity: 1, translateY: 0 }}
|
|
158
|
+
transition={{ type: "timing", duration: 500, delay: 400 }}
|
|
159
|
+
>
|
|
160
|
+
<Card className="bg-slate-900/40 border-slate-800 p-6 rounded-2xl border">
|
|
161
|
+
<Text className="text-white text-lg font-bold mb-4">Your Security Profile</Text>
|
|
162
|
+
|
|
163
|
+
<View className="gap-6">
|
|
164
|
+
<View>
|
|
165
|
+
<Text className="text-slate-400 text-xs font-bold uppercase tracking-wider mb-2">
|
|
166
|
+
Assigned Roles
|
|
167
|
+
</Text>
|
|
168
|
+
<View className="flex-row flex-wrap gap-2">
|
|
169
|
+
{roles.length > 0 ? (
|
|
170
|
+
roles.map((role) => (
|
|
171
|
+
<View key={role} className="bg-blue-500/10 border border-blue-500/25 px-3 py-1 rounded-full">
|
|
172
|
+
<Text className="text-blue-400 text-xs font-bold">{role}</Text>
|
|
173
|
+
</View>
|
|
174
|
+
))
|
|
175
|
+
) : (
|
|
176
|
+
<Text className="text-slate-500 text-sm font-medium">No roles assigned.</Text>
|
|
177
|
+
)}
|
|
178
|
+
</View>
|
|
179
|
+
</View>
|
|
180
|
+
|
|
181
|
+
<View>
|
|
182
|
+
<Text className="text-slate-400 text-xs font-bold uppercase tracking-wider mb-2">
|
|
183
|
+
Permissions Matrix
|
|
184
|
+
</Text>
|
|
185
|
+
<View className="flex-row flex-wrap gap-2">
|
|
186
|
+
{permissions.length > 0 ? (
|
|
187
|
+
permissions.map((perm) => (
|
|
188
|
+
<View key={perm} className="bg-emerald-500/10 border border-emerald-500/25 px-3 py-1 rounded-full">
|
|
189
|
+
<Text className="text-emerald-400 text-xs font-semibold">{perm}</Text>
|
|
190
|
+
</View>
|
|
191
|
+
))
|
|
192
|
+
) : (
|
|
193
|
+
<Text className="text-slate-500 text-sm font-medium">No permissions granted.</Text>
|
|
194
|
+
)}
|
|
195
|
+
</View>
|
|
196
|
+
</View>
|
|
197
|
+
</View>
|
|
198
|
+
</Card>
|
|
199
|
+
</MotiView>
|
|
200
|
+
|
|
201
|
+
{/* Auth Cookie Details Card */}
|
|
202
|
+
<MotiView
|
|
203
|
+
from={{ opacity: 0, translateY: 20 }}
|
|
204
|
+
animate={{ opacity: 1, translateY: 0 }}
|
|
205
|
+
transition={{ type: "timing", duration: 500, delay: 500 }}
|
|
206
|
+
>
|
|
207
|
+
<Card className="bg-slate-900/40 border-slate-800 p-6 rounded-2xl border">
|
|
208
|
+
<Text className="text-white text-lg font-bold mb-2">Session Information</Text>
|
|
209
|
+
<Text className="text-slate-400 text-xs font-medium mb-4">
|
|
210
|
+
Secure HTTP cookies safeguard client sessions with Cross-Site scripting prevention.
|
|
211
|
+
</Text>
|
|
212
|
+
|
|
213
|
+
<View className="bg-slate-950/60 p-4 rounded-xl border border-slate-800/80 gap-3">
|
|
214
|
+
<View className="flex-row justify-between items-center">
|
|
215
|
+
<Text className="text-slate-400 text-xs font-bold">API Base Endpoint</Text>
|
|
216
|
+
<Text className="text-slate-300 text-xs font-mono">{authClient.baseURL}</Text>
|
|
217
|
+
</View>
|
|
218
|
+
<View className="flex-row justify-between items-center">
|
|
219
|
+
<Text className="text-slate-400 text-xs font-bold">Environment</Text>
|
|
220
|
+
<Text className="text-blue-400 text-xs font-semibold uppercase">{Platform.OS}</Text>
|
|
221
|
+
</View>
|
|
222
|
+
<View className="flex-row justify-between items-center">
|
|
223
|
+
<Text className="text-slate-400 text-xs font-bold">Verification State</Text>
|
|
224
|
+
<Text className="text-emerald-400 text-xs font-bold">Encrypted</Text>
|
|
225
|
+
</View>
|
|
226
|
+
</View>
|
|
227
|
+
</Card>
|
|
228
|
+
</MotiView>
|
|
229
|
+
</View>
|
|
230
|
+
</ScrollView>
|
|
231
|
+
);
|
|
232
|
+
}
|