@nexttylabs/echo 0.5.0 → 0.6.0
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/CHANGELOG.md +14 -0
- package/app/(dashboard)/admin/feedback/[id]/edit/page.tsx +12 -6
- package/app/(dashboard)/admin/feedback/new/page.tsx +19 -17
- package/app/(dashboard)/admin/layout.tsx +16 -6
- package/app/(dashboard)/layout.tsx +4 -2
- package/app/(dashboard)/settings/api-keys/page.tsx +13 -3
- package/app/(dashboard)/settings/layout.tsx +25 -2
- package/app/(dashboard)/settings/organization/page.tsx +8 -9
- package/app/api/feedback/[id]/comments/[commentId]/route.ts +13 -4
- package/app/api/feedback/[id]/reclassify/route.ts +4 -4
- package/app/api/organizations/handler.ts +2 -4
- package/components/settings/settings-sidebar.tsx +4 -4
- package/hooks/use-organization.tsx +116 -0
- package/hooks/use-permissions.ts +24 -11
- package/lib/auth/organization.ts +20 -0
- package/lib/auth/permissions.ts +10 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# @nexttylabs/echo
|
|
2
2
|
|
|
3
|
+
## 0.6.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- cc04d9f: Use the organization member role uniformly.
|
|
8
|
+
- 5cfdeb7: feat: support editing user profile
|
|
9
|
+
|
|
10
|
+
### Patch Changes
|
|
11
|
+
|
|
12
|
+
- 7dba561: remove legacy changeset files
|
|
13
|
+
- 1e798f1: remove unuse tables and published files
|
|
14
|
+
- e3c4e1c: fix workflow errors
|
|
15
|
+
- daf9abb: first release
|
|
16
|
+
|
|
3
17
|
## 0.5.0
|
|
4
18
|
|
|
5
19
|
### Minor Changes
|
|
@@ -22,7 +22,7 @@ import { auth } from "@/lib/auth/config";
|
|
|
22
22
|
import { db } from "@/lib/db";
|
|
23
23
|
import { feedback } from "@/lib/db/schema";
|
|
24
24
|
import { FeedbackEditForm } from "@/components/feedback/feedback-edit-form";
|
|
25
|
-
import { canEditFeedback
|
|
25
|
+
import { canEditFeedback } from "@/lib/auth/permissions";
|
|
26
26
|
import { getOrgContext } from "@/lib/auth/org-context";
|
|
27
27
|
import { getRequestUrl } from "@/lib/http/get-request-url";
|
|
28
28
|
|
|
@@ -39,11 +39,6 @@ export default async function FeedbackEditPage({ params }: PageProps) {
|
|
|
39
39
|
redirect("/login");
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
const userRole = (session.user as { role?: string }).role as UserRole | undefined;
|
|
43
|
-
if (!userRole || !canEditFeedback(userRole)) {
|
|
44
|
-
redirect("/admin/feedback");
|
|
45
|
-
}
|
|
46
|
-
|
|
47
42
|
const { id } = await params;
|
|
48
43
|
const feedbackId = parseInt(id);
|
|
49
44
|
|
|
@@ -55,6 +50,7 @@ export default async function FeedbackEditPage({ params }: PageProps) {
|
|
|
55
50
|
throw new Error("Database not configured");
|
|
56
51
|
}
|
|
57
52
|
|
|
53
|
+
// Get organization context first
|
|
58
54
|
let organizationId: string | null = null;
|
|
59
55
|
try {
|
|
60
56
|
const url = getRequestUrl(
|
|
@@ -72,6 +68,16 @@ export default async function FeedbackEditPage({ params }: PageProps) {
|
|
|
72
68
|
notFound();
|
|
73
69
|
}
|
|
74
70
|
|
|
71
|
+
// Get user role from organization membership
|
|
72
|
+
const { getUserRoleInOrganization } = await import("@/lib/auth/organization");
|
|
73
|
+
const userRole = organizationId
|
|
74
|
+
? await getUserRoleInOrganization(db, session.user.id, organizationId)
|
|
75
|
+
: null;
|
|
76
|
+
|
|
77
|
+
if (!userRole || !canEditFeedback(userRole)) {
|
|
78
|
+
redirect("/admin/feedback");
|
|
79
|
+
}
|
|
80
|
+
|
|
75
81
|
const [row] = await db
|
|
76
82
|
.select({
|
|
77
83
|
title: feedback.title,
|
|
@@ -30,17 +30,21 @@ export default async function NewFeedbackPage() {
|
|
|
30
30
|
redirect("/login");
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
if (!db) {
|
|
34
|
+
throw new Error("Database not configured");
|
|
35
|
+
}
|
|
35
36
|
|
|
36
|
-
|
|
37
|
+
// Get user's organization first (which includes their role)
|
|
38
|
+
const organization = await getUserOrganization(db, session.user.id);
|
|
39
|
+
|
|
40
|
+
if (!organization) {
|
|
37
41
|
return (
|
|
38
42
|
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-100 px-4 py-12">
|
|
39
43
|
<div className="mx-auto flex w-full max-w-2xl flex-col gap-6">
|
|
40
|
-
<div className="rounded-lg border border-
|
|
41
|
-
<h1 className="text-xl font-semibold text-
|
|
42
|
-
<p className="mt-2 text-sm text-
|
|
43
|
-
|
|
44
|
+
<div className="rounded-lg border border-amber-200 bg-amber-50 p-6">
|
|
45
|
+
<h1 className="text-xl font-semibold text-amber-800">未找到组织</h1>
|
|
46
|
+
<p className="mt-2 text-sm text-amber-700">
|
|
47
|
+
请先加入组织后再代客户提交反馈。
|
|
44
48
|
</p>
|
|
45
49
|
</div>
|
|
46
50
|
</div>
|
|
@@ -48,20 +52,18 @@ export default async function NewFeedbackPage() {
|
|
|
48
52
|
);
|
|
49
53
|
}
|
|
50
54
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
const organization = await getUserOrganization(db, session.user.id);
|
|
55
|
+
// Get user role from organization membership
|
|
56
|
+
const userRole = (organization.role as UserRole) || "customer";
|
|
57
|
+
const hasSubmitOnBehalfPermission = canSubmitOnBehalf(userRole);
|
|
56
58
|
|
|
57
|
-
if (!
|
|
59
|
+
if (!hasSubmitOnBehalfPermission) {
|
|
58
60
|
return (
|
|
59
61
|
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-100 px-4 py-12">
|
|
60
62
|
<div className="mx-auto flex w-full max-w-2xl flex-col gap-6">
|
|
61
|
-
<div className="rounded-lg border border-
|
|
62
|
-
<h1 className="text-xl font-semibold text-
|
|
63
|
-
<p className="mt-2 text-sm text-
|
|
64
|
-
|
|
63
|
+
<div className="rounded-lg border border-red-200 bg-red-50 p-6">
|
|
64
|
+
<h1 className="text-xl font-semibold text-red-800">权限不足</h1>
|
|
65
|
+
<p className="mt-2 text-sm text-red-600">
|
|
66
|
+
您没有代客户提交反馈的权限。请联系管理员获取相应权限。
|
|
65
67
|
</p>
|
|
66
68
|
</div>
|
|
67
69
|
</div>
|
|
@@ -15,10 +15,11 @@
|
|
|
15
15
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
import { headers } from "next/headers";
|
|
18
|
+
import { cookies, headers } from "next/headers";
|
|
19
19
|
import { redirect } from "next/navigation";
|
|
20
20
|
import { auth } from "@/lib/auth/config";
|
|
21
|
-
import
|
|
21
|
+
import { db } from "@/lib/db";
|
|
22
|
+
import { getUserRoleInOrganization } from "@/lib/auth/organization";
|
|
22
23
|
|
|
23
24
|
export default async function AdminLayout({
|
|
24
25
|
children,
|
|
@@ -36,13 +37,22 @@ export default async function AdminLayout({
|
|
|
36
37
|
redirect("/login");
|
|
37
38
|
}
|
|
38
39
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
// Get current organization ID from cookie
|
|
41
|
+
const cookieStore = await cookies();
|
|
42
|
+
const currentOrgId = cookieStore.get("orgId")?.value;
|
|
42
43
|
|
|
43
|
-
if (
|
|
44
|
+
if (!db || !currentOrgId) {
|
|
45
|
+
redirect("/no-access");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Get user's role in the current organization
|
|
49
|
+
const role = await getUserRoleInOrganization(db, session.user.id, currentOrgId);
|
|
50
|
+
|
|
51
|
+
// Only admin or owner can access admin pages
|
|
52
|
+
if (role !== "admin" && role !== "owner" && role !== "product_manager") {
|
|
44
53
|
redirect("/no-access");
|
|
45
54
|
}
|
|
46
55
|
|
|
47
56
|
return <>{children}</>;
|
|
48
57
|
}
|
|
58
|
+
|
|
@@ -39,8 +39,6 @@ export default async function DashboardRootLayout({
|
|
|
39
39
|
redirect("/login");
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
const userRole = (session.user as { role?: string }).role as UserRole || "customer";
|
|
43
|
-
|
|
44
42
|
// Fetch organizations
|
|
45
43
|
let organizations: Array<{ id: string; name: string; slug: string; role: string }> = [];
|
|
46
44
|
let currentOrgId: string | null = null;
|
|
@@ -52,6 +50,10 @@ export default async function DashboardRootLayout({
|
|
|
52
50
|
currentOrgId = cookieOrgId || organizations[0]?.id || null;
|
|
53
51
|
}
|
|
54
52
|
|
|
53
|
+
// Get user role from current organization membership
|
|
54
|
+
const currentOrg = organizations.find((org) => org.id === currentOrgId);
|
|
55
|
+
const userRole = (currentOrg?.role as UserRole) || "customer";
|
|
56
|
+
|
|
55
57
|
return (
|
|
56
58
|
<DashboardLayout
|
|
57
59
|
user={{
|
|
@@ -15,11 +15,13 @@
|
|
|
15
15
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
import { headers } from "next/headers";
|
|
18
|
+
import { cookies, headers } from "next/headers";
|
|
19
19
|
import { redirect } from "next/navigation";
|
|
20
20
|
import { getTranslations } from "next-intl/server";
|
|
21
21
|
import { auth } from "@/lib/auth/config";
|
|
22
|
+
import { db } from "@/lib/db";
|
|
22
23
|
import { ApiKeysList } from "@/components/settings/api-keys-list";
|
|
24
|
+
import { getUserRoleInOrganization } from "@/lib/auth/organization";
|
|
23
25
|
import type { UserRole } from "@/lib/auth/permissions";
|
|
24
26
|
|
|
25
27
|
export async function generateMetadata() {
|
|
@@ -37,9 +39,17 @@ export default async function ApiKeysSettingsPage() {
|
|
|
37
39
|
redirect("/login");
|
|
38
40
|
}
|
|
39
41
|
|
|
40
|
-
|
|
42
|
+
// Get user role from current organization
|
|
43
|
+
const cookieStore = await cookies();
|
|
44
|
+
const currentOrgId = cookieStore.get("orgId")?.value;
|
|
41
45
|
|
|
42
|
-
|
|
46
|
+
let userRole: UserRole = "customer";
|
|
47
|
+
if (db && currentOrgId) {
|
|
48
|
+
const role = await getUserRoleInOrganization(db, session.user.id, currentOrgId);
|
|
49
|
+
userRole = role || "customer";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (userRole !== "owner" && userRole !== "admin" && userRole !== "product_manager") {
|
|
43
53
|
redirect("/settings/profile");
|
|
44
54
|
}
|
|
45
55
|
|
|
@@ -15,10 +15,12 @@
|
|
|
15
15
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
import { headers } from "next/headers";
|
|
18
|
+
import { cookies, headers } from "next/headers";
|
|
19
19
|
import { redirect } from "next/navigation";
|
|
20
20
|
import { auth } from "@/lib/auth/config";
|
|
21
|
+
import { db } from "@/lib/db";
|
|
21
22
|
import { SettingsSidebar } from "@/components/settings";
|
|
23
|
+
import { getUserOrganizations } from "@/lib/auth/organization";
|
|
22
24
|
import type { UserRole } from "@/lib/auth/permissions";
|
|
23
25
|
|
|
24
26
|
export default async function SettingsLayout({
|
|
@@ -32,7 +34,27 @@ export default async function SettingsLayout({
|
|
|
32
34
|
redirect("/login");
|
|
33
35
|
}
|
|
34
36
|
|
|
35
|
-
|
|
37
|
+
// Get user role from current organization (same logic as dashboard layout)
|
|
38
|
+
let userRole: UserRole = "customer";
|
|
39
|
+
|
|
40
|
+
if (db) {
|
|
41
|
+
const organizations = await getUserOrganizations(db, session.user.id);
|
|
42
|
+
const cookieStore = await cookies();
|
|
43
|
+
const cookieOrgId = cookieStore.get("orgId")?.value ?? null;
|
|
44
|
+
|
|
45
|
+
// Check if the cookie org exists in user's organizations
|
|
46
|
+
// If not, fall back to the first available organization
|
|
47
|
+
let currentOrg = cookieOrgId
|
|
48
|
+
? organizations.find((org) => org.id === cookieOrgId)
|
|
49
|
+
: null;
|
|
50
|
+
|
|
51
|
+
// Fallback to first org if cookie org is not found (stale cookie)
|
|
52
|
+
if (!currentOrg && organizations.length > 0) {
|
|
53
|
+
currentOrg = organizations[0];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
userRole = (currentOrg?.role as UserRole) || "customer";
|
|
57
|
+
}
|
|
36
58
|
|
|
37
59
|
return (
|
|
38
60
|
<div className="flex min-h-[calc(100vh-3.5rem)]">
|
|
@@ -41,3 +63,4 @@ export default async function SettingsLayout({
|
|
|
41
63
|
</div>
|
|
42
64
|
);
|
|
43
65
|
}
|
|
66
|
+
|
|
@@ -38,30 +38,29 @@ export default async function OrganizationSettingsPage() {
|
|
|
38
38
|
redirect("/login");
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
const userRole = (session.user as { role?: string }).role as UserRole || "customer";
|
|
42
|
-
|
|
43
|
-
if (userRole !== "admin") {
|
|
44
|
-
redirect("/settings/profile");
|
|
45
|
-
}
|
|
46
|
-
|
|
47
41
|
if (!db) {
|
|
48
42
|
throw new Error("Database connection not available");
|
|
49
43
|
}
|
|
50
44
|
|
|
51
|
-
// Get all user organizations
|
|
45
|
+
// Get all user organizations and find current one
|
|
52
46
|
const organizations = await getUserOrganizations(db, session.user.id);
|
|
53
47
|
|
|
54
48
|
if (organizations.length === 0) {
|
|
55
49
|
redirect("/settings/organizations/new");
|
|
56
50
|
}
|
|
57
51
|
|
|
58
|
-
// Get current organization from cookie
|
|
52
|
+
// Get current organization from cookie
|
|
59
53
|
const cookieStore = await cookies();
|
|
60
54
|
const cookieOrgId = cookieStore.get("orgId")?.value ?? null;
|
|
61
55
|
const currentOrgId = cookieOrgId || organizations[0]?.id || null;
|
|
62
56
|
|
|
63
|
-
// Find the current organization
|
|
57
|
+
// Find the current organization and get role from it
|
|
64
58
|
const organization = organizations.find(org => org.id === currentOrgId) || organizations[0];
|
|
59
|
+
const userRole = (organization?.role as UserRole) || "customer";
|
|
60
|
+
|
|
61
|
+
if (userRole !== "owner" && userRole !== "admin") {
|
|
62
|
+
redirect("/settings/profile");
|
|
63
|
+
}
|
|
65
64
|
|
|
66
65
|
if (!organization) {
|
|
67
66
|
redirect("/settings/organizations/new");
|
|
@@ -60,9 +60,17 @@ export async function DELETE(req: NextRequest, { params }: RouteParams) {
|
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
const userId = session.user.id;
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
63
|
+
|
|
64
|
+
// Get user role from organization membership
|
|
65
|
+
const { cookies } = await import("next/headers");
|
|
66
|
+
const cookieStore = await cookies();
|
|
67
|
+
const currentOrgId = cookieStore.get("orgId")?.value;
|
|
68
|
+
|
|
69
|
+
let userRole: UserRole | null = null;
|
|
70
|
+
if (currentOrgId) {
|
|
71
|
+
const { getUserRoleInOrganization } = await import("@/lib/auth/organization");
|
|
72
|
+
userRole = await getUserRoleInOrganization(db, userId, currentOrgId);
|
|
73
|
+
}
|
|
66
74
|
|
|
67
75
|
const [existingComment] = await db
|
|
68
76
|
.select({
|
|
@@ -80,8 +88,9 @@ export async function DELETE(req: NextRequest, { params }: RouteParams) {
|
|
|
80
88
|
);
|
|
81
89
|
}
|
|
82
90
|
|
|
91
|
+
// Users can delete their own comments, or admins/owners can delete any
|
|
83
92
|
const canDelete =
|
|
84
|
-
existingComment.userId === userId || userRole === "admin";
|
|
93
|
+
existingComment.userId === userId || userRole === "admin" || userRole === "owner";
|
|
85
94
|
|
|
86
95
|
if (!canDelete) {
|
|
87
96
|
return NextResponse.json(
|
|
@@ -21,7 +21,7 @@ import { eq, and } from "drizzle-orm";
|
|
|
21
21
|
import { db } from "@/lib/db";
|
|
22
22
|
import { feedback } from "@/lib/db/schema";
|
|
23
23
|
import { auth } from "@/lib/auth/config";
|
|
24
|
-
import { canUpdateFeedbackStatus
|
|
24
|
+
import { canUpdateFeedbackStatus } from "@/lib/auth/permissions";
|
|
25
25
|
import { classifyFeedback } from "@/lib/services/ai/classifier";
|
|
26
26
|
import { apiError } from "@/lib/api/errors";
|
|
27
27
|
import { getOrgContext } from "@/lib/auth/org-context";
|
|
@@ -81,9 +81,9 @@ export async function POST(
|
|
|
81
81
|
);
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
84
|
+
// Get user role from organization membership (via context)
|
|
85
|
+
const { getUserRoleInOrganization } = await import("@/lib/auth/organization");
|
|
86
|
+
const userRole = await getUserRoleInOrganization(db, session.user.id, context.organizationId);
|
|
87
87
|
|
|
88
88
|
if (!userRole || !canUpdateFeedbackStatus(userRole)) {
|
|
89
89
|
return NextResponse.json(
|
|
@@ -43,9 +43,7 @@ export function buildCreateOrganizationHandler(deps: CreateOrganizationDeps) {
|
|
|
43
43
|
if (!session) {
|
|
44
44
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
45
45
|
}
|
|
46
|
-
|
|
47
|
-
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
|
48
|
-
}
|
|
46
|
+
// Any authenticated user can create organizations - they become the owner
|
|
49
47
|
|
|
50
48
|
let body: unknown;
|
|
51
49
|
try {
|
|
@@ -87,7 +85,7 @@ export function buildCreateOrganizationHandler(deps: CreateOrganizationDeps) {
|
|
|
87
85
|
await tx.insert(organizationMembers).values({
|
|
88
86
|
organizationId,
|
|
89
87
|
userId: session.user.id,
|
|
90
|
-
role: "
|
|
88
|
+
role: "owner",
|
|
91
89
|
});
|
|
92
90
|
return [created];
|
|
93
91
|
});
|
|
@@ -32,15 +32,15 @@ interface SettingsSidebarProps {
|
|
|
32
32
|
export function SettingsSidebar({ userRole }: SettingsSidebarProps) {
|
|
33
33
|
const pathname = usePathname();
|
|
34
34
|
const t = useTranslations("settings");
|
|
35
|
-
const
|
|
36
|
-
const isAdminOrPM =
|
|
35
|
+
const isOwnerOrAdmin = userRole === "owner" || userRole === "admin";
|
|
36
|
+
const isAdminOrPM = isOwnerOrAdmin || userRole === "product_manager";
|
|
37
37
|
|
|
38
38
|
const groupedItems = [
|
|
39
39
|
{
|
|
40
40
|
title: t("groups.general"),
|
|
41
41
|
items: [
|
|
42
42
|
{ href: "/settings/profile", label: t("items.profile"), icon: User },
|
|
43
|
-
{ href: "/settings/organization", label: t("items.organization"), icon: Users, show:
|
|
43
|
+
{ href: "/settings/organization", label: t("items.organization"), icon: Users, show: isOwnerOrAdmin },
|
|
44
44
|
],
|
|
45
45
|
},
|
|
46
46
|
{
|
|
@@ -71,7 +71,7 @@ export function SettingsSidebar({ userRole }: SettingsSidebarProps) {
|
|
|
71
71
|
{ href: "/settings/widgets", label: t("items.widgetsEmbeds"), icon: LayoutGrid, show: isAdminOrPM },
|
|
72
72
|
{ href: "/settings/integrations", label: t("items.integrations"), icon: Plug, show: isAdminOrPM },
|
|
73
73
|
|
|
74
|
-
{ href: "/settings/danger-zone", label: t("items.dangerZone"), icon: AlertTriangle, show:
|
|
74
|
+
{ href: "/settings/danger-zone", label: t("items.dangerZone"), icon: AlertTriangle, show: isOwnerOrAdmin },
|
|
75
75
|
],
|
|
76
76
|
},
|
|
77
77
|
];
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (c) 2026 Echo Team
|
|
3
|
+
*
|
|
4
|
+
* This program is free software: you can redistribute it and/or modify
|
|
5
|
+
* it under the terms of the GNU Affero General Public License as published by
|
|
6
|
+
* the Free Software Foundation, either version 3 of the License, or
|
|
7
|
+
* (at your option) any later version.
|
|
8
|
+
*
|
|
9
|
+
* This program is distributed in the hope that it will be useful,
|
|
10
|
+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
11
|
+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
12
|
+
* GNU Affero General Public License for more details.
|
|
13
|
+
*
|
|
14
|
+
* You should have received a copy of the GNU Affero General Public License
|
|
15
|
+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
"use client";
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
createContext,
|
|
22
|
+
useContext,
|
|
23
|
+
useState,
|
|
24
|
+
useCallback,
|
|
25
|
+
type ReactNode,
|
|
26
|
+
} from "react";
|
|
27
|
+
import type { UserRole } from "@/lib/auth/permissions";
|
|
28
|
+
|
|
29
|
+
export interface Organization {
|
|
30
|
+
id: string;
|
|
31
|
+
name: string;
|
|
32
|
+
slug: string;
|
|
33
|
+
role: UserRole;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface OrganizationContextValue {
|
|
37
|
+
currentOrganization: Organization | null;
|
|
38
|
+
organizations: Organization[];
|
|
39
|
+
setOrganizations: (orgs: Organization[], currentId?: string | null) => void;
|
|
40
|
+
setCurrentOrganization: (orgId: string) => void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const OrganizationContext = createContext<OrganizationContextValue | null>(
|
|
44
|
+
null
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
interface OrganizationProviderProps {
|
|
48
|
+
children: ReactNode;
|
|
49
|
+
initialOrganizations?: Organization[];
|
|
50
|
+
initialCurrentOrgId?: string | null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function OrganizationProvider({
|
|
54
|
+
children,
|
|
55
|
+
initialOrganizations = [],
|
|
56
|
+
initialCurrentOrgId,
|
|
57
|
+
}: OrganizationProviderProps) {
|
|
58
|
+
const [organizations, setOrganizationsState] =
|
|
59
|
+
useState<Organization[]>(initialOrganizations);
|
|
60
|
+
const [currentOrganization, setCurrentOrganizationState] =
|
|
61
|
+
useState<Organization | null>(() => {
|
|
62
|
+
if (initialCurrentOrgId) {
|
|
63
|
+
return (
|
|
64
|
+
initialOrganizations.find((o) => o.id === initialCurrentOrgId) || null
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
return initialOrganizations[0] || null;
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const setOrganizations = useCallback(
|
|
71
|
+
(orgs: Organization[], currentId?: string | null) => {
|
|
72
|
+
setOrganizationsState(orgs);
|
|
73
|
+
const current = currentId ? orgs.find((o) => o.id === currentId) : orgs[0];
|
|
74
|
+
setCurrentOrganizationState(current || null);
|
|
75
|
+
},
|
|
76
|
+
[]
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
const setCurrentOrganization = useCallback(
|
|
80
|
+
(orgId: string) => {
|
|
81
|
+
const org = organizations.find((o) => o.id === orgId);
|
|
82
|
+
if (org) {
|
|
83
|
+
setCurrentOrganizationState(org);
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
[organizations]
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
<OrganizationContext.Provider
|
|
91
|
+
value={{
|
|
92
|
+
currentOrganization,
|
|
93
|
+
organizations,
|
|
94
|
+
setOrganizations,
|
|
95
|
+
setCurrentOrganization,
|
|
96
|
+
}}
|
|
97
|
+
>
|
|
98
|
+
{children}
|
|
99
|
+
</OrganizationContext.Provider>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function useOrganization(): OrganizationContextValue {
|
|
104
|
+
const context = useContext(OrganizationContext);
|
|
105
|
+
if (!context) {
|
|
106
|
+
throw new Error(
|
|
107
|
+
"useOrganization must be used within an OrganizationProvider"
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
return context;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function useCurrentRole(): UserRole | null {
|
|
114
|
+
const { currentOrganization } = useOrganization();
|
|
115
|
+
return currentOrganization?.role || null;
|
|
116
|
+
}
|
package/hooks/use-permissions.ts
CHANGED
|
@@ -18,34 +18,46 @@
|
|
|
18
18
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
-
import { authClient } from "@/lib/auth/client";
|
|
22
21
|
import {
|
|
23
22
|
hasAllPermissions,
|
|
24
23
|
hasPermission,
|
|
25
24
|
type Permission,
|
|
26
25
|
type UserRole,
|
|
27
26
|
} from "@/lib/auth/permissions";
|
|
27
|
+
import { useCurrentRole } from "@/hooks/use-organization";
|
|
28
28
|
|
|
29
|
-
|
|
30
|
-
|
|
29
|
+
/**
|
|
30
|
+
* Check if the current user has a specific permission.
|
|
31
|
+
* Uses the user's role in the current organization from OrganizationProvider context.
|
|
32
|
+
*
|
|
33
|
+
* @param permission - The permission to check
|
|
34
|
+
* @param roleOverride - Optional role to use instead of the context role
|
|
35
|
+
* @returns true if the user has the permission
|
|
36
|
+
*/
|
|
31
37
|
export function useCan(
|
|
32
38
|
permission: Permission,
|
|
33
|
-
|
|
39
|
+
roleOverride?: UserRole | null,
|
|
34
40
|
): boolean {
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
const role = (session?.user as { role?: UserRole })?.role;
|
|
41
|
+
const contextRole = useCurrentRole();
|
|
42
|
+
const role = roleOverride ?? contextRole;
|
|
38
43
|
|
|
39
44
|
return role ? hasPermission(role, permission) : false;
|
|
40
45
|
}
|
|
41
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Check if the current user has all of the specified permissions.
|
|
49
|
+
* Uses the user's role in the current organization from OrganizationProvider context.
|
|
50
|
+
*
|
|
51
|
+
* @param permissions - Single permission or array of permissions to check
|
|
52
|
+
* @param roleOverride - Optional role to use instead of the context role
|
|
53
|
+
* @returns true if the user has all permissions
|
|
54
|
+
*/
|
|
42
55
|
export function useHasPermission(
|
|
43
56
|
permissions: Permission | Permission[],
|
|
44
|
-
|
|
57
|
+
roleOverride?: UserRole | null,
|
|
45
58
|
): boolean {
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
const role = (session?.user as { role?: UserRole })?.role;
|
|
59
|
+
const contextRole = useCurrentRole();
|
|
60
|
+
const role = roleOverride ?? contextRole;
|
|
49
61
|
|
|
50
62
|
if (!role) {
|
|
51
63
|
return false;
|
|
@@ -54,3 +66,4 @@ export function useHasPermission(
|
|
|
54
66
|
const list = Array.isArray(permissions) ? permissions : [permissions];
|
|
55
67
|
return hasAllPermissions(role, list);
|
|
56
68
|
}
|
|
69
|
+
|
package/lib/auth/organization.ts
CHANGED
|
@@ -19,6 +19,7 @@ import { and, eq } from "drizzle-orm";
|
|
|
19
19
|
import { db } from "@/lib/db";
|
|
20
20
|
import type { db as database } from "@/lib/db";
|
|
21
21
|
import { organizationMembers, organizations } from "@/lib/db/schema";
|
|
22
|
+
import type { UserRole } from "@/lib/auth/permissions";
|
|
22
23
|
|
|
23
24
|
type Database = NonNullable<typeof database>;
|
|
24
25
|
|
|
@@ -105,3 +106,22 @@ export async function getCurrentOrganizationId(userId: string): Promise<string |
|
|
|
105
106
|
const org = await getUserOrganization(db, userId);
|
|
106
107
|
return org?.id || null;
|
|
107
108
|
}
|
|
109
|
+
|
|
110
|
+
export async function getUserRoleInOrganization(
|
|
111
|
+
db: Database,
|
|
112
|
+
userId: string,
|
|
113
|
+
organizationId: string
|
|
114
|
+
): Promise<UserRole | null> {
|
|
115
|
+
const [member] = await db
|
|
116
|
+
.select({ role: organizationMembers.role })
|
|
117
|
+
.from(organizationMembers)
|
|
118
|
+
.where(
|
|
119
|
+
and(
|
|
120
|
+
eq(organizationMembers.userId, userId),
|
|
121
|
+
eq(organizationMembers.organizationId, organizationId)
|
|
122
|
+
)
|
|
123
|
+
)
|
|
124
|
+
.limit(1);
|
|
125
|
+
|
|
126
|
+
return (member?.role as UserRole) || null;
|
|
127
|
+
}
|
package/lib/auth/permissions.ts
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
export type UserRole =
|
|
19
|
+
| "owner"
|
|
19
20
|
| "admin"
|
|
20
21
|
| "product_manager"
|
|
21
22
|
| "developer"
|
|
@@ -35,6 +36,15 @@ export const PERMISSIONS = {
|
|
|
35
36
|
export type Permission = (typeof PERMISSIONS)[keyof typeof PERMISSIONS];
|
|
36
37
|
|
|
37
38
|
export const ROLE_PERMISSIONS: Record<UserRole, Permission[]> = {
|
|
39
|
+
owner: [
|
|
40
|
+
PERMISSIONS.CREATE_FEEDBACK,
|
|
41
|
+
PERMISSIONS.SUBMIT_ON_BEHALF,
|
|
42
|
+
PERMISSIONS.DELETE_FEEDBACK,
|
|
43
|
+
PERMISSIONS.MANAGE_ORG,
|
|
44
|
+
PERMISSIONS.UPDATE_FEEDBACK_STATUS,
|
|
45
|
+
PERMISSIONS.BACKUP_CREATE,
|
|
46
|
+
PERMISSIONS.BACKUP_VIEW,
|
|
47
|
+
],
|
|
38
48
|
admin: [
|
|
39
49
|
PERMISSIONS.CREATE_FEEDBACK,
|
|
40
50
|
PERMISSIONS.SUBMIT_ON_BEHALF,
|