@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,212 @@
|
|
|
1
|
+
import React, { useEffect, useState } from "react";
|
|
2
|
+
import { View as MotiView } from "moti";
|
|
3
|
+
import { View, Text, ActivityIndicator, TouchableOpacity, Platform, Image } from "react-native";
|
|
4
|
+
import { authClient } from "../../utils/auth";
|
|
5
|
+
import { LoginScreen } from "../auth/login";
|
|
6
|
+
import { DashboardView } from "../dashboard";
|
|
7
|
+
import { UserManagementView } from "../management/users";
|
|
8
|
+
import { RolesManagementView } from "../management/roles";
|
|
9
|
+
|
|
10
|
+
const logoSource = Platform.select({
|
|
11
|
+
web: { uri: "/logo.svg" },
|
|
12
|
+
default: require("../../../../assets/logo.png"),
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
export function HomeScreen() {
|
|
16
|
+
const { data: session, isPending: sessionPending } = authClient.useSession();
|
|
17
|
+
const [me, setMe] = useState<{ roles: string[]; permissions: string[] } | null>(null);
|
|
18
|
+
const [mePending, setMePending] = useState(false);
|
|
19
|
+
const [activeTab, setActiveTab] = useState("dashboard"); // "dashboard" | "users" | "roles"
|
|
20
|
+
|
|
21
|
+
const fetchProfile = async () => {
|
|
22
|
+
if (!session) return;
|
|
23
|
+
setMePending(true);
|
|
24
|
+
try {
|
|
25
|
+
const res = await fetch(`${authClient.baseURL}/api/me`, {
|
|
26
|
+
headers: {
|
|
27
|
+
"Content-Type": "application/json",
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
const data = await res.json();
|
|
31
|
+
if (!data.error) {
|
|
32
|
+
setMe({
|
|
33
|
+
roles: data.roles,
|
|
34
|
+
permissions: data.permissions,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
} catch (err) {
|
|
38
|
+
console.error("Failed to load profile details:", err);
|
|
39
|
+
} finally {
|
|
40
|
+
setMePending(false);
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
if (session) {
|
|
46
|
+
fetchProfile();
|
|
47
|
+
} else {
|
|
48
|
+
setMe(null);
|
|
49
|
+
}
|
|
50
|
+
}, [session]);
|
|
51
|
+
|
|
52
|
+
const handleSignOut = async () => {
|
|
53
|
+
try {
|
|
54
|
+
await authClient.signOut();
|
|
55
|
+
setMe(null);
|
|
56
|
+
setActiveTab("dashboard");
|
|
57
|
+
} catch (error) {
|
|
58
|
+
console.error("Sign out failed:", error);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
if (sessionPending || (session && mePending)) {
|
|
63
|
+
return (
|
|
64
|
+
<View className="flex-1 items-center justify-center bg-slate-950">
|
|
65
|
+
<ActivityIndicator size="large" color="#3b82f6" />
|
|
66
|
+
</View>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// If not authenticated, render LoginScreen
|
|
71
|
+
if (!session) {
|
|
72
|
+
return (
|
|
73
|
+
<View className="flex-1 items-center justify-center bg-slate-950 px-6 py-12 relative overflow-hidden">
|
|
74
|
+
{/* Glowing visual background lights */}
|
|
75
|
+
<View className="absolute top-1/4 left-1/4 w-80 h-80 bg-blue-600/10 rounded-full blur-3xl" />
|
|
76
|
+
<View className="absolute bottom-1/4 right-1/4 w-80 h-80 bg-indigo-600/10 rounded-full blur-3xl" />
|
|
77
|
+
|
|
78
|
+
{/* Main Branding Header */}
|
|
79
|
+
<View className="items-center mb-6">
|
|
80
|
+
<Image
|
|
81
|
+
source={logoSource}
|
|
82
|
+
style={{ width: 80, height: 80, marginBottom: 16, borderRadius: 16 }}
|
|
83
|
+
resizeMode="contain"
|
|
84
|
+
/>
|
|
85
|
+
<Text className="text-5xl font-black tracking-tight text-white mb-2">
|
|
86
|
+
THUNDER<Text className="text-yellow-500">.stack</Text>
|
|
87
|
+
</Text>
|
|
88
|
+
<Text className="text-slate-400 text-sm font-medium text-center max-w-sm leading-6">
|
|
89
|
+
Unified Solito Interface • Cross-Platform Web & Mobile
|
|
90
|
+
</Text>
|
|
91
|
+
</View>
|
|
92
|
+
|
|
93
|
+
<LoginScreen onSuccess={fetchProfile} />
|
|
94
|
+
</View>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Permissions mapping helper
|
|
99
|
+
const hasPermission = (perm: string) => {
|
|
100
|
+
return me?.permissions?.includes(perm) ?? false;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
return (
|
|
104
|
+
<View className="flex-1 bg-slate-950 flex-row flex-wrap">
|
|
105
|
+
{/* Dynamic Visual Glowing Background Lights */}
|
|
106
|
+
<View className="absolute top-1/4 left-1/4 w-96 h-96 bg-blue-600/5 rounded-full blur-3xl" />
|
|
107
|
+
<View className="absolute bottom-1/4 right-1/4 w-96 h-96 bg-indigo-600/5 rounded-full blur-3xl" />
|
|
108
|
+
|
|
109
|
+
{/* Navigation Layout Header/Sidebar */}
|
|
110
|
+
{/* On Web/Desktop we can render a sidebar, on mobile we render a top header */}
|
|
111
|
+
<View className="w-full md:w-64 bg-slate-900/40 border-b md:border-b-0 md:border-r border-slate-900 p-6 flex-col justify-between">
|
|
112
|
+
<View>
|
|
113
|
+
{/* Logo Branding */}
|
|
114
|
+
<View className="mb-8 flex-row items-center justify-between">
|
|
115
|
+
<View className="flex-row items-center gap-3">
|
|
116
|
+
<Image
|
|
117
|
+
source={logoSource}
|
|
118
|
+
style={{ width: 32, height: 32, borderRadius: 8 }}
|
|
119
|
+
resizeMode="contain"
|
|
120
|
+
/>
|
|
121
|
+
<Text className="text-2xl font-black text-white tracking-tight">
|
|
122
|
+
THUNDER<Text className="text-yellow-500">.stack</Text>
|
|
123
|
+
</Text>
|
|
124
|
+
</View>
|
|
125
|
+
<View className="bg-emerald-500/10 border border-emerald-500/20 px-2 py-0.5 rounded-full">
|
|
126
|
+
<Text className="text-emerald-400 text-[10px] font-bold uppercase tracking-wider">Live</Text>
|
|
127
|
+
</View>
|
|
128
|
+
</View>
|
|
129
|
+
|
|
130
|
+
{/* Navigation Items */}
|
|
131
|
+
<View className="gap-2">
|
|
132
|
+
<TouchableOpacity
|
|
133
|
+
onPress={() => setActiveTab("dashboard")}
|
|
134
|
+
className={`w-full px-4 py-3 rounded-xl flex-row items-center gap-3 ${
|
|
135
|
+
activeTab === "dashboard" ? "bg-blue-600/10 border border-blue-500/20" : "border border-transparent"
|
|
136
|
+
}`}
|
|
137
|
+
>
|
|
138
|
+
<Text className={`font-bold text-sm ${activeTab === "dashboard" ? "text-blue-400" : "text-slate-400"}`}>
|
|
139
|
+
📊 Dashboard
|
|
140
|
+
</Text>
|
|
141
|
+
</TouchableOpacity>
|
|
142
|
+
|
|
143
|
+
{hasPermission("manage:user") && (
|
|
144
|
+
<TouchableOpacity
|
|
145
|
+
onPress={() => setActiveTab("users")}
|
|
146
|
+
className={`w-full px-4 py-3 rounded-xl flex-row items-center gap-3 ${
|
|
147
|
+
activeTab === "users" ? "bg-blue-600/10 border border-blue-500/20" : "border border-transparent"
|
|
148
|
+
}`}
|
|
149
|
+
>
|
|
150
|
+
<Text className={`font-bold text-sm ${activeTab === "users" ? "text-blue-400" : "text-slate-400"}`}>
|
|
151
|
+
👥 Users
|
|
152
|
+
</Text>
|
|
153
|
+
</TouchableOpacity>
|
|
154
|
+
)}
|
|
155
|
+
|
|
156
|
+
{(hasPermission("manage:role") || hasPermission("manage:role-perm")) && (
|
|
157
|
+
<TouchableOpacity
|
|
158
|
+
onPress={() => setActiveTab("roles")}
|
|
159
|
+
className={`w-full px-4 py-3 rounded-xl flex-row items-center gap-3 ${
|
|
160
|
+
activeTab === "roles" ? "bg-blue-600/10 border border-blue-500/20" : "border border-transparent"
|
|
161
|
+
}`}
|
|
162
|
+
>
|
|
163
|
+
<Text className={`font-bold text-sm ${activeTab === "roles" ? "text-blue-400" : "text-slate-400"}`}>
|
|
164
|
+
🔑 Roles & Perms
|
|
165
|
+
</Text>
|
|
166
|
+
</TouchableOpacity>
|
|
167
|
+
)}
|
|
168
|
+
</View>
|
|
169
|
+
</View>
|
|
170
|
+
|
|
171
|
+
{/* User Card & Logout inside Sidebar */}
|
|
172
|
+
<View className="mt-8 border-t border-slate-900 pt-6 gap-4">
|
|
173
|
+
<View className="flex-row items-center gap-3">
|
|
174
|
+
<View className="w-9 h-9 rounded-full bg-slate-800 border border-slate-700 items-center justify-center">
|
|
175
|
+
<Text className="text-white text-sm font-bold">
|
|
176
|
+
{session?.user?.name?.charAt(0) ?? "U"}
|
|
177
|
+
</Text>
|
|
178
|
+
</View>
|
|
179
|
+
<View className="flex-1">
|
|
180
|
+
<Text className="text-white text-xs font-bold leading-4 truncate max-w-[140px]" numberOfLines={1}>
|
|
181
|
+
{session?.user?.name}
|
|
182
|
+
</Text>
|
|
183
|
+
<Text className="text-slate-500 text-[10px] truncate max-w-[140px]" numberOfLines={1}>
|
|
184
|
+
{session?.user?.email}
|
|
185
|
+
</Text>
|
|
186
|
+
</View>
|
|
187
|
+
</View>
|
|
188
|
+
|
|
189
|
+
<TouchableOpacity
|
|
190
|
+
onPress={handleSignOut}
|
|
191
|
+
className="w-full bg-slate-950/40 border border-slate-800/80 py-2.5 rounded-xl items-center"
|
|
192
|
+
>
|
|
193
|
+
<Text className="text-slate-400 text-xs font-bold">Sign Out</Text>
|
|
194
|
+
</TouchableOpacity>
|
|
195
|
+
</View>
|
|
196
|
+
</View>
|
|
197
|
+
|
|
198
|
+
{/* Main Content Area */}
|
|
199
|
+
<View className="flex-1 min-w-[280px]">
|
|
200
|
+
{activeTab === "dashboard" && (
|
|
201
|
+
<DashboardView
|
|
202
|
+
user={session.user}
|
|
203
|
+
roles={me?.roles ?? []}
|
|
204
|
+
permissions={me?.permissions ?? []}
|
|
205
|
+
/>
|
|
206
|
+
)}
|
|
207
|
+
{activeTab === "users" && hasPermission("manage:user") && <UserManagementView />}
|
|
208
|
+
{activeTab === "roles" && (hasPermission("manage:role") || hasPermission("manage:role-perm")) && <RolesManagementView />}
|
|
209
|
+
</View>
|
|
210
|
+
</View>
|
|
211
|
+
);
|
|
212
|
+
}
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
import React, { useEffect, useState } from "react";
|
|
2
|
+
import { View as MotiView } from "moti";
|
|
3
|
+
import { View, Text, ActivityIndicator, ScrollView, RefreshControl, TextInput, TouchableOpacity } from "react-native";
|
|
4
|
+
import { Card } from "../../components/Card";
|
|
5
|
+
import { authClient } from "../../utils/auth";
|
|
6
|
+
|
|
7
|
+
function RolesSkeleton() {
|
|
8
|
+
return (
|
|
9
|
+
<ScrollView className="flex-1">
|
|
10
|
+
<View className="p-6 gap-8">
|
|
11
|
+
<View>
|
|
12
|
+
<View className="h-3 w-20 bg-slate-800 rounded mb-2" />
|
|
13
|
+
<View className="h-8 w-48 bg-slate-800 rounded" />
|
|
14
|
+
</View>
|
|
15
|
+
|
|
16
|
+
{/* Create Role Form Skeleton */}
|
|
17
|
+
<MotiView
|
|
18
|
+
from={{ opacity: 0.3 }}
|
|
19
|
+
animate={{ opacity: 0.7 }}
|
|
20
|
+
transition={{
|
|
21
|
+
type: "timing",
|
|
22
|
+
duration: 900,
|
|
23
|
+
loop: true,
|
|
24
|
+
}}
|
|
25
|
+
className="bg-slate-900/40 border-slate-800 p-6 rounded-2xl border gap-4"
|
|
26
|
+
>
|
|
27
|
+
<View className="h-5 w-40 bg-slate-800 rounded mb-2" />
|
|
28
|
+
<View className="h-10 w-full bg-slate-800 rounded-xl" />
|
|
29
|
+
<View className="h-10 w-full bg-slate-800 rounded-xl" />
|
|
30
|
+
<View className="h-10 w-full bg-slate-800 rounded-xl" />
|
|
31
|
+
</MotiView>
|
|
32
|
+
|
|
33
|
+
{/* Role Cards List Skeleton */}
|
|
34
|
+
<View className="gap-6">
|
|
35
|
+
<View className="h-6 w-44 bg-slate-800 rounded mb-2" />
|
|
36
|
+
{[1, 2].map((i) => (
|
|
37
|
+
<MotiView
|
|
38
|
+
key={i}
|
|
39
|
+
from={{ opacity: 0.3 }}
|
|
40
|
+
animate={{ opacity: 0.7 }}
|
|
41
|
+
transition={{
|
|
42
|
+
type: "timing",
|
|
43
|
+
duration: 900,
|
|
44
|
+
loop: true,
|
|
45
|
+
delay: i * 200,
|
|
46
|
+
}}
|
|
47
|
+
className="bg-slate-900/40 border-slate-800 p-6 rounded-2xl border gap-4 min-h-[160px]"
|
|
48
|
+
>
|
|
49
|
+
<View className="flex-row justify-between items-start">
|
|
50
|
+
<View className="gap-2">
|
|
51
|
+
<View className="h-5 w-32 bg-slate-800 rounded" />
|
|
52
|
+
<View className="h-3.5 w-64 bg-slate-800 rounded" />
|
|
53
|
+
</View>
|
|
54
|
+
</View>
|
|
55
|
+
<View className="mt-2 border-t border-slate-800/60 pt-4 gap-3">
|
|
56
|
+
<View className="h-3 w-36 bg-slate-800 rounded" />
|
|
57
|
+
<View className="flex-row gap-2 flex-wrap">
|
|
58
|
+
{[1, 2, 3, 4].map((j) => (
|
|
59
|
+
<View key={j} className="w-24 h-8 bg-slate-800 rounded-full" />
|
|
60
|
+
))}
|
|
61
|
+
</View>
|
|
62
|
+
</View>
|
|
63
|
+
</MotiView>
|
|
64
|
+
))}
|
|
65
|
+
</View>
|
|
66
|
+
</View>
|
|
67
|
+
</ScrollView>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function RolesManagementView() {
|
|
72
|
+
const [roles, setRoles] = useState<any[]>([]);
|
|
73
|
+
const [permissions, setPermissions] = useState<any[]>([]);
|
|
74
|
+
const [loading, setLoading] = useState(true);
|
|
75
|
+
const [refreshing, setRefreshing] = useState(false);
|
|
76
|
+
const [newRoleName, setNewRoleName] = useState("");
|
|
77
|
+
const [newRoleDesc, setNewRoleDesc] = useState("");
|
|
78
|
+
const [actionLoading, setActionLoading] = useState(false);
|
|
79
|
+
const [error, setError] = useState("");
|
|
80
|
+
|
|
81
|
+
const fetchData = async () => {
|
|
82
|
+
try {
|
|
83
|
+
const rolesRes = await fetch(`${authClient.baseURL}/api/management/roles`);
|
|
84
|
+
const rolesData = await rolesRes.json();
|
|
85
|
+
|
|
86
|
+
const permsRes = await fetch(`${authClient.baseURL}/api/management/permissions`);
|
|
87
|
+
const permsData = await permsRes.json();
|
|
88
|
+
|
|
89
|
+
if (rolesRes.status === 403 || permsRes.status === 403) {
|
|
90
|
+
setError("Permission denied.");
|
|
91
|
+
} else {
|
|
92
|
+
setRoles(rolesData);
|
|
93
|
+
setPermissions(permsData);
|
|
94
|
+
}
|
|
95
|
+
} catch (err) {
|
|
96
|
+
console.error("Failed to load roles and permissions:", err);
|
|
97
|
+
setError("Failed to fetch data.");
|
|
98
|
+
} finally {
|
|
99
|
+
setLoading(false);
|
|
100
|
+
setRefreshing(false);
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
fetchData();
|
|
106
|
+
}, []);
|
|
107
|
+
|
|
108
|
+
const handleCreateRole = async () => {
|
|
109
|
+
if (!newRoleName) {
|
|
110
|
+
setError("Role name is required.");
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
setActionLoading(true);
|
|
115
|
+
setError("");
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
const res = await fetch(`${authClient.baseURL}/api/management/roles`, {
|
|
119
|
+
method: "POST",
|
|
120
|
+
headers: {
|
|
121
|
+
"Content-Type": "application/json",
|
|
122
|
+
},
|
|
123
|
+
body: JSON.stringify({ name: newRoleName, description: newRoleDesc }),
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
if (!res.ok) {
|
|
127
|
+
const errorData = await res.json();
|
|
128
|
+
throw new Error(errorData.error || "Failed to create role.");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
setNewRoleName("");
|
|
132
|
+
setNewRoleDesc("");
|
|
133
|
+
await fetchData();
|
|
134
|
+
} catch (err: any) {
|
|
135
|
+
setError(err?.message || "An error occurred during role creation.");
|
|
136
|
+
} finally {
|
|
137
|
+
setActionLoading(false);
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const handleDeleteRole = async (roleId: string) => {
|
|
142
|
+
if (roleId === "role_admin" || roleId === "role_user") {
|
|
143
|
+
setError("Default system roles (Admin/User) cannot be deleted.");
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
setActionLoading(true);
|
|
148
|
+
setError("");
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
const res = await fetch(`${authClient.baseURL}/api/management/roles/${roleId}`, {
|
|
152
|
+
method: "DELETE",
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
if (!res.ok) {
|
|
156
|
+
const errorData = await res.json();
|
|
157
|
+
throw new Error(errorData.error || "Failed to delete role.");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
await fetchData();
|
|
161
|
+
} catch (err: any) {
|
|
162
|
+
setError(err?.message || "Failed to delete role.");
|
|
163
|
+
} finally {
|
|
164
|
+
setActionLoading(false);
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const handleTogglePermission = async (roleId: string, permissionId: string, currentlyAssigned: boolean) => {
|
|
169
|
+
setActionLoading(true);
|
|
170
|
+
setError("");
|
|
171
|
+
|
|
172
|
+
try {
|
|
173
|
+
const url = `${authClient.baseURL}/api/management/roles/${roleId}/permissions${
|
|
174
|
+
currentlyAssigned ? `/${permissionId}` : ""
|
|
175
|
+
}`;
|
|
176
|
+
const method = currentlyAssigned ? "DELETE" : "POST";
|
|
177
|
+
|
|
178
|
+
const res = await fetch(url, {
|
|
179
|
+
method,
|
|
180
|
+
headers: {
|
|
181
|
+
"Content-Type": "application/json",
|
|
182
|
+
},
|
|
183
|
+
body: method === "POST" ? JSON.stringify({ permissionId }) : undefined,
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
if (!res.ok) {
|
|
187
|
+
const errorData = await res.json();
|
|
188
|
+
throw new Error(errorData.error || "Failed to update permission mapping.");
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
await fetchData();
|
|
192
|
+
} catch (err: any) {
|
|
193
|
+
setError(err?.message || "Failed to toggle permission.");
|
|
194
|
+
} finally {
|
|
195
|
+
setActionLoading(false);
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
const onRefresh = () => {
|
|
200
|
+
setRefreshing(true);
|
|
201
|
+
fetchData();
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
if (loading) {
|
|
205
|
+
return <RolesSkeleton />;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return (
|
|
209
|
+
<ScrollView
|
|
210
|
+
className="flex-1"
|
|
211
|
+
refreshControl={
|
|
212
|
+
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor="#3b82f6" />
|
|
213
|
+
}
|
|
214
|
+
>
|
|
215
|
+
<View className="p-6 gap-8">
|
|
216
|
+
{/* Title */}
|
|
217
|
+
<MotiView
|
|
218
|
+
from={{ opacity: 0, translateY: -10 }}
|
|
219
|
+
animate={{ opacity: 1, translateY: 0 }}
|
|
220
|
+
transition={{ type: "timing", duration: 400 }}
|
|
221
|
+
>
|
|
222
|
+
<Text className="text-slate-400 text-xs font-bold uppercase tracking-widest mb-1">
|
|
223
|
+
Administration
|
|
224
|
+
</Text>
|
|
225
|
+
<Text className="text-white text-3xl font-black tracking-tight">
|
|
226
|
+
Roles & Permissions
|
|
227
|
+
</Text>
|
|
228
|
+
</MotiView>
|
|
229
|
+
|
|
230
|
+
{error ? (
|
|
231
|
+
<View className="p-4 bg-red-950/40 border border-red-500/20 rounded-xl">
|
|
232
|
+
<Text className="text-red-400 text-xs font-semibold text-center">{error}</Text>
|
|
233
|
+
</View>
|
|
234
|
+
) : null}
|
|
235
|
+
|
|
236
|
+
{/* Create Role Form */}
|
|
237
|
+
<MotiView
|
|
238
|
+
from={{ opacity: 0, scale: 0.98 }}
|
|
239
|
+
animate={{ opacity: 1, scale: 1 }}
|
|
240
|
+
transition={{ type: "timing", duration: 400 }}
|
|
241
|
+
>
|
|
242
|
+
<Card className="bg-slate-900/40 border-slate-800 p-6 rounded-2xl border">
|
|
243
|
+
<Text className="text-white text-lg font-bold mb-4">Create New Custom Role</Text>
|
|
244
|
+
<View className="gap-4">
|
|
245
|
+
<View>
|
|
246
|
+
<Text className="text-slate-400 text-xs font-semibold uppercase tracking-wider mb-2">
|
|
247
|
+
Role Name
|
|
248
|
+
</Text>
|
|
249
|
+
<TextInput
|
|
250
|
+
className="w-full bg-slate-950/60 border border-slate-800 text-white rounded-xl px-4 py-3 text-sm focus:border-blue-500"
|
|
251
|
+
placeholder="e.g. Editor"
|
|
252
|
+
placeholderTextColor="#64748b"
|
|
253
|
+
value={newRoleName}
|
|
254
|
+
onChangeText={setNewRoleName}
|
|
255
|
+
/>
|
|
256
|
+
</View>
|
|
257
|
+
|
|
258
|
+
<View>
|
|
259
|
+
<Text className="text-slate-400 text-xs font-semibold uppercase tracking-wider mb-2">
|
|
260
|
+
Description
|
|
261
|
+
</Text>
|
|
262
|
+
<TextInput
|
|
263
|
+
className="w-full bg-slate-950/60 border border-slate-800 text-white rounded-xl px-4 py-3 text-sm focus:border-blue-500"
|
|
264
|
+
placeholder="Role description here..."
|
|
265
|
+
placeholderTextColor="#64748b"
|
|
266
|
+
value={newRoleDesc}
|
|
267
|
+
onChangeText={setNewRoleDesc}
|
|
268
|
+
/>
|
|
269
|
+
</View>
|
|
270
|
+
|
|
271
|
+
{actionLoading ? (
|
|
272
|
+
<ActivityIndicator size="small" color="#3b82f6" className="my-2" />
|
|
273
|
+
) : (
|
|
274
|
+
<TouchableOpacity
|
|
275
|
+
onPress={handleCreateRole}
|
|
276
|
+
className="w-full bg-blue-600 border border-blue-500 rounded-xl py-3 items-center"
|
|
277
|
+
>
|
|
278
|
+
<Text className="text-white font-extrabold text-sm">Create Role</Text>
|
|
279
|
+
</TouchableOpacity>
|
|
280
|
+
)}
|
|
281
|
+
</View>
|
|
282
|
+
</Card>
|
|
283
|
+
</MotiView>
|
|
284
|
+
|
|
285
|
+
{/* Roles List & Matrix */}
|
|
286
|
+
<View className="gap-6">
|
|
287
|
+
<Text className="text-white text-xl font-bold">System Role Mappings</Text>
|
|
288
|
+
|
|
289
|
+
{roles.map((roleItem, index) => {
|
|
290
|
+
const rolePerms = roleItem.permissions.map((p: any) => p.name);
|
|
291
|
+
const isSystemRole = roleItem.id === "role_admin" || roleItem.id === "role_user";
|
|
292
|
+
|
|
293
|
+
return (
|
|
294
|
+
<MotiView
|
|
295
|
+
key={roleItem.id}
|
|
296
|
+
from={{ opacity: 0, translateY: 15 }}
|
|
297
|
+
animate={{ opacity: 1, translateY: 0 }}
|
|
298
|
+
transition={{ type: "timing", duration: 400, delay: index * 50 }}
|
|
299
|
+
>
|
|
300
|
+
<Card className="bg-slate-900/40 border-slate-800 p-6 rounded-2xl border gap-4">
|
|
301
|
+
<View className="flex-row justify-between items-start">
|
|
302
|
+
<View className="flex-1">
|
|
303
|
+
<Text className="text-white text-lg font-extrabold">{roleItem.name}</Text>
|
|
304
|
+
<Text className="text-slate-400 text-xs mt-1">{roleItem.description}</Text>
|
|
305
|
+
</View>
|
|
306
|
+
{!isSystemRole && (
|
|
307
|
+
<TouchableOpacity
|
|
308
|
+
onPress={() => handleDeleteRole(roleItem.id)}
|
|
309
|
+
disabled={actionLoading}
|
|
310
|
+
className="bg-red-950/20 border border-red-500/20 px-3 py-1 rounded-md"
|
|
311
|
+
>
|
|
312
|
+
<Text className="text-red-400 text-xs font-bold">Delete</Text>
|
|
313
|
+
</TouchableOpacity>
|
|
314
|
+
)}
|
|
315
|
+
</View>
|
|
316
|
+
|
|
317
|
+
<View className="mt-2 border-t border-slate-800/60 pt-4">
|
|
318
|
+
<Text className="text-slate-500 text-xs font-bold uppercase tracking-wider mb-3">
|
|
319
|
+
Permission Assignments
|
|
320
|
+
</Text>
|
|
321
|
+
|
|
322
|
+
<View className="flex-row flex-wrap gap-2">
|
|
323
|
+
{permissions.map((perm) => {
|
|
324
|
+
const isAssigned = rolePerms.includes(perm.name);
|
|
325
|
+
return (
|
|
326
|
+
<TouchableOpacity
|
|
327
|
+
key={perm.id}
|
|
328
|
+
onPress={() => handleTogglePermission(roleItem.id, perm.id, isAssigned)}
|
|
329
|
+
disabled={actionLoading}
|
|
330
|
+
className={`flex-row items-center gap-1.5 px-3 py-1.5 rounded-full border ${
|
|
331
|
+
isAssigned
|
|
332
|
+
? "bg-emerald-500/10 border-emerald-500/30"
|
|
333
|
+
: "bg-slate-950/40 border-slate-800"
|
|
334
|
+
}`}
|
|
335
|
+
>
|
|
336
|
+
<Text
|
|
337
|
+
className={`text-xs font-semibold ${
|
|
338
|
+
isAssigned ? "text-emerald-400" : "text-slate-400"
|
|
339
|
+
}`}
|
|
340
|
+
>
|
|
341
|
+
{isAssigned ? "✓" : "+"} {perm.name}
|
|
342
|
+
</Text>
|
|
343
|
+
</TouchableOpacity>
|
|
344
|
+
);
|
|
345
|
+
})}
|
|
346
|
+
</View>
|
|
347
|
+
</View>
|
|
348
|
+
</Card>
|
|
349
|
+
</MotiView>
|
|
350
|
+
);
|
|
351
|
+
})}
|
|
352
|
+
</View>
|
|
353
|
+
</View>
|
|
354
|
+
</ScrollView>
|
|
355
|
+
);
|
|
356
|
+
}
|