@xenterprises/nuxt-x-auth-better 0.3.2 → 0.4.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/README.md +193 -0
- package/app/app.config.ts +19 -0
- package/app/components/XAuth/OrganizationCreate.vue +65 -0
- package/app/components/XAuth/OrganizationInvitations.vue +48 -0
- package/app/components/XAuth/OrganizationInvite.vue +73 -0
- package/app/components/XAuth/OrganizationMembers.vue +103 -0
- package/app/components/XAuth/OrganizationSettings.vue +85 -0
- package/app/components/XAuth/OrganizationSwitcher.vue +61 -0
- package/app/components/XAuth/TeamCreate.vue +64 -0
- package/app/components/XAuth/TeamList.vue +49 -0
- package/app/components/XAuth/TeamMembers.vue +123 -0
- package/app/components/XAuth/TeamSettings.vue +82 -0
- package/app/composables/types.ts +73 -0
- package/app/composables/useOrganization.ts +532 -0
- package/app/composables/useXAuth.ts +11 -5
- package/app/middleware/organization.global.ts +125 -0
- package/app/pages/auth/handler/invitation.vue +63 -0
- package/app/pages/org/[slug]/index.vue +53 -0
- package/app/pages/org/[slug]/invitations.vue +23 -0
- package/app/pages/org/[slug]/invite.vue +30 -0
- package/app/pages/org/[slug]/members.vue +37 -0
- package/app/pages/org/[slug]/settings.vue +39 -0
- package/app/pages/org/[slug]/teams/[teamId]/index.vue +51 -0
- package/app/pages/org/[slug]/teams/create.vue +30 -0
- package/app/pages/org/[slug]/teams/index.vue +37 -0
- package/app/pages/org/create.vue +13 -0
- package/app/pages/org/index.vue +20 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -257,6 +257,199 @@ This layer uses Better Auth's official Vue client (`better-auth/vue`) via `creat
|
|
|
257
257
|
|
|
258
258
|
5. **Token access**: `getToken()` and `getAuthHeaders()` extract the access token from the Better Auth session for use with your own API calls.
|
|
259
259
|
|
|
260
|
+
## Organizations & Teams
|
|
261
|
+
|
|
262
|
+
The layer includes a full BetterAuth Organizations & Teams implementation. Enable it in `app.config.ts`:
|
|
263
|
+
|
|
264
|
+
```ts
|
|
265
|
+
xAuth: {
|
|
266
|
+
features: {
|
|
267
|
+
organization: true,
|
|
268
|
+
teams: true,
|
|
269
|
+
},
|
|
270
|
+
organization: {
|
|
271
|
+
enabled: true,
|
|
272
|
+
teams: true,
|
|
273
|
+
},
|
|
274
|
+
}
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
### `useOrganization()` Composable
|
|
278
|
+
|
|
279
|
+
```vue
|
|
280
|
+
<script setup>
|
|
281
|
+
const {
|
|
282
|
+
// State
|
|
283
|
+
organizations,
|
|
284
|
+
organizationsLoading,
|
|
285
|
+
activeOrganization,
|
|
286
|
+
activeLoading,
|
|
287
|
+
activeMember,
|
|
288
|
+
activeMemberRole,
|
|
289
|
+
members,
|
|
290
|
+
invitations,
|
|
291
|
+
teams,
|
|
292
|
+
isOrgAdmin,
|
|
293
|
+
isOrgOwner,
|
|
294
|
+
canCreateOrganization,
|
|
295
|
+
|
|
296
|
+
// Org CRUD
|
|
297
|
+
createOrganization,
|
|
298
|
+
updateOrganization,
|
|
299
|
+
deleteOrganization,
|
|
300
|
+
setActiveOrganization,
|
|
301
|
+
getFullOrganization,
|
|
302
|
+
checkSlug,
|
|
303
|
+
|
|
304
|
+
// Members
|
|
305
|
+
listMembers,
|
|
306
|
+
updateMemberRole,
|
|
307
|
+
removeMember,
|
|
308
|
+
leaveOrganization,
|
|
309
|
+
|
|
310
|
+
// Invitations
|
|
311
|
+
inviteMember,
|
|
312
|
+
cancelInvitation,
|
|
313
|
+
acceptInvitation,
|
|
314
|
+
rejectInvitation,
|
|
315
|
+
getInvitation,
|
|
316
|
+
listInvitations,
|
|
317
|
+
listUserInvitations,
|
|
318
|
+
|
|
319
|
+
// Teams
|
|
320
|
+
listTeams,
|
|
321
|
+
createTeam,
|
|
322
|
+
updateTeam,
|
|
323
|
+
deleteTeam,
|
|
324
|
+
setActiveTeam,
|
|
325
|
+
listTeamMembers,
|
|
326
|
+
addTeamMember,
|
|
327
|
+
removeTeamMember,
|
|
328
|
+
listUserTeams,
|
|
329
|
+
|
|
330
|
+
// Access control
|
|
331
|
+
hasPermission,
|
|
332
|
+
checkRolePermission,
|
|
333
|
+
getDefaultPermissions,
|
|
334
|
+
} = useOrganization()
|
|
335
|
+
</script>
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
#### Reactive State
|
|
339
|
+
|
|
340
|
+
| Property | Type | Description |
|
|
341
|
+
|----------|------|-------------|
|
|
342
|
+
| `organizations` | `Ref<Organization[]>` | All orgs the user belongs to |
|
|
343
|
+
| `organizationsLoading` | `Ref<boolean>` | Whether the org list is loading |
|
|
344
|
+
| `activeOrganization` | `Ref<Organization \| null>` | Currently active organization |
|
|
345
|
+
| `activeLoading` | `Ref<boolean>` | Whether the active org is loading |
|
|
346
|
+
| `activeMember` | `Ref<OrganizationMember \| null>` | Current user's membership in active org |
|
|
347
|
+
| `activeMemberRole` | `Ref<string \| null>` | Role of current user in active org |
|
|
348
|
+
| `members` | `ComputedRef<OrganizationMember[]>` | Members of the active org |
|
|
349
|
+
| `invitations` | `ComputedRef<OrganizationInvitation[]>` | Pending invitations for the active org |
|
|
350
|
+
| `teams` | `ComputedRef<OrganizationTeam[]>` | Teams in the active org |
|
|
351
|
+
| `isOrgAdmin` | `ComputedRef<boolean>` | User is owner or admin of active org |
|
|
352
|
+
| `isOrgOwner` | `ComputedRef<boolean>` | User is owner of active org |
|
|
353
|
+
| `canCreateOrganization` | `ComputedRef<boolean>` | User can create new organizations |
|
|
354
|
+
|
|
355
|
+
#### Methods
|
|
356
|
+
|
|
357
|
+
| Method | Description |
|
|
358
|
+
|--------|-------------|
|
|
359
|
+
| `createOrganization(name, slug?, logo?)` | Create a new organization |
|
|
360
|
+
| `updateOrganization(data)` | Update organization details |
|
|
361
|
+
| `deleteOrganization(organizationId?)` | Delete an organization |
|
|
362
|
+
| `setActiveOrganization(organizationId?, organizationSlug?)` | Set the active organization |
|
|
363
|
+
| `getFullOrganization(organizationId?, organizationSlug?)` | Fetch full org details |
|
|
364
|
+
| `checkSlug(slug)` | Check if a slug is available |
|
|
365
|
+
| `listMembers(organizationId?)` | List organization members |
|
|
366
|
+
| `updateMemberRole(memberId, role, organizationId?)` | Update a member's role |
|
|
367
|
+
| `removeMember(memberId, organizationId?)` | Remove a member |
|
|
368
|
+
| `leaveOrganization()` | Leave the active organization |
|
|
369
|
+
| `inviteMember(email, role, organizationId?)` | Invite a user by email |
|
|
370
|
+
| `cancelInvitation(invitationId)` | Cancel a pending invitation |
|
|
371
|
+
| `acceptInvitation(invitationId)` | Accept an invitation |
|
|
372
|
+
| `rejectInvitation(invitationId)` | Reject an invitation |
|
|
373
|
+
| `getInvitation(invitationId)` | Fetch invitation details |
|
|
374
|
+
| `listInvitations(organizationId?)` | List org invitations |
|
|
375
|
+
| `listUserInvitations()` | List current user's invitations |
|
|
376
|
+
| `listTeams(organizationId?)` | List teams in an org |
|
|
377
|
+
| `createTeam(name, organizationId?)` | Create a team |
|
|
378
|
+
| `updateTeam(teamId, data, organizationId?)` | Update a team |
|
|
379
|
+
| `deleteTeam(teamId, organizationId?)` | Delete a team |
|
|
380
|
+
| `setActiveTeam(teamId)` | Set the active team |
|
|
381
|
+
| `listTeamMembers(teamId)` | List members of a team |
|
|
382
|
+
| `addTeamMember(teamId, memberId, organizationId?)` | Add a member to a team |
|
|
383
|
+
| `removeTeamMember(teamId, memberId)` | Remove a member from a team |
|
|
384
|
+
| `listUserTeams()` | List teams for current user |
|
|
385
|
+
| `hasPermission(permissionCheck)` | Check if active member has a permission |
|
|
386
|
+
| `checkRolePermission(role, permissions)` | Check permissions for a specific role |
|
|
387
|
+
| `getDefaultPermissions(role)` | Get default permissions for a role |
|
|
388
|
+
|
|
389
|
+
### Components
|
|
390
|
+
|
|
391
|
+
| Component | Description |
|
|
392
|
+
|-----------|-------------|
|
|
393
|
+
| `XAuthOrganizationSwitcher` | List and switch between organizations |
|
|
394
|
+
| `XAuthOrganizationCreate` | Create a new organization |
|
|
395
|
+
| `XAuthOrganizationSettings` | Edit / delete organization |
|
|
396
|
+
| `XAuthOrganizationMembers` | Manage organization members |
|
|
397
|
+
| `XAuthOrganizationInvite` | Invite a new member |
|
|
398
|
+
| `XAuthOrganizationInvitations` | List pending invitations |
|
|
399
|
+
| `XAuthTeamList` | List teams in an organization |
|
|
400
|
+
| `XAuthTeamCreate` | Create a team |
|
|
401
|
+
| `XAuthTeamSettings` | Edit / delete team |
|
|
402
|
+
| `XAuthTeamMembers` | Manage team members |
|
|
403
|
+
|
|
404
|
+
### Pages (auto-registered)
|
|
405
|
+
|
|
406
|
+
| Path | Description |
|
|
407
|
+
|------|-------------|
|
|
408
|
+
| `/org` | Organization list / switcher |
|
|
409
|
+
| `/org/create` | Create a new organization |
|
|
410
|
+
| `/org/:slug` | Organization dashboard |
|
|
411
|
+
| `/org/:slug/settings` | Organization settings |
|
|
412
|
+
| `/org/:slug/members` | Member list |
|
|
413
|
+
| `/org/:slug/invite` | Invite member |
|
|
414
|
+
| `/org/:slug/invitations` | Pending invitations |
|
|
415
|
+
| `/org/:slug/teams` | Team list |
|
|
416
|
+
| `/org/:slug/teams/create` | Create team |
|
|
417
|
+
| `/org/:slug/teams/:teamId` | Team settings + members |
|
|
418
|
+
| `/auth/handler/invitation?id=...` | Public invitation accept handler |
|
|
419
|
+
|
|
420
|
+
### Middleware
|
|
421
|
+
|
|
422
|
+
`organization.global.ts` runs on every navigation. For routes under `/org` (or any path configured in `organization.protectedRoutes`), it:
|
|
423
|
+
|
|
424
|
+
1. Verifies organizations are enabled.
|
|
425
|
+
2. Redirects unauthenticated users to the login page.
|
|
426
|
+
3. Resolves the requested organization by slug and sets it active.
|
|
427
|
+
4. Redirects non-members to the `notMember` redirect path.
|
|
428
|
+
5. Enforces `requireOwner`, `requireAdmin`, and `requiredPermissions` rules on matched `OrganizationRoute` objects.
|
|
429
|
+
|
|
430
|
+
### Server Setup & Custom Roles
|
|
431
|
+
|
|
432
|
+
Add the `organization` plugin to your BetterAuth server config and enable teams on both server and client:
|
|
433
|
+
|
|
434
|
+
```ts
|
|
435
|
+
// server/auth.ts
|
|
436
|
+
import { betterAuth } from 'better-auth'
|
|
437
|
+
import { organization } from 'better-auth/plugins'
|
|
438
|
+
|
|
439
|
+
export const auth = betterAuth({
|
|
440
|
+
plugins: [
|
|
441
|
+
organization({
|
|
442
|
+
allowUserToCreateOrganization: true,
|
|
443
|
+
teams: {
|
|
444
|
+
enabled: true,
|
|
445
|
+
},
|
|
446
|
+
}),
|
|
447
|
+
],
|
|
448
|
+
})
|
|
449
|
+
```
|
|
450
|
+
|
|
451
|
+
Custom access-control roles must be configured **on both server and client**. See `INTEGRATION.md` for details.
|
|
452
|
+
|
|
260
453
|
## Layer Architecture
|
|
261
454
|
|
|
262
455
|
- **`nuxt.config.ts`**: Registers `@nuxt/ui` module, loads the auth CSS, and defines `runtimeConfig.public.x.auth` for base URL and auth path.
|
package/app/app.config.ts
CHANGED
|
@@ -21,6 +21,25 @@ export default defineAppConfig({
|
|
|
21
21
|
otp: false,
|
|
22
22
|
forgotPassword: true,
|
|
23
23
|
signup: true,
|
|
24
|
+
organization: false,
|
|
25
|
+
teams: false,
|
|
26
|
+
},
|
|
27
|
+
|
|
28
|
+
organization: {
|
|
29
|
+
enabled: false,
|
|
30
|
+
teams: true,
|
|
31
|
+
allowUserToCreateOrganization: true,
|
|
32
|
+
requireMemberEmailVerification: true,
|
|
33
|
+
defaultRole: 'member',
|
|
34
|
+
protectedRoutes: ['/org'],
|
|
35
|
+
redirects: {
|
|
36
|
+
create: '/org/create',
|
|
37
|
+
list: '/org',
|
|
38
|
+
settings: '/org/:slug/settings',
|
|
39
|
+
invitations: '/org/:slug/invitations',
|
|
40
|
+
notMember: '/org',
|
|
41
|
+
noPermission: '/org',
|
|
42
|
+
},
|
|
24
43
|
},
|
|
25
44
|
|
|
26
45
|
oauthProviders: [] as Array<{
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="w-full space-y-6">
|
|
3
|
+
<div class="text-center">
|
|
4
|
+
<h1 class="text-2xl font-semibold text-highlighted">Create organization</h1>
|
|
5
|
+
<p class="text-sm text-muted mt-1">Set up a new organization for your team.</p>
|
|
6
|
+
</div>
|
|
7
|
+
|
|
8
|
+
<UForm :schema="schema" :state="state" class="space-y-4" @submit="handleCreate">
|
|
9
|
+
<UFormField label="Name" name="name">
|
|
10
|
+
<UInput v-model="state.name" placeholder="Acme Inc." class="w-full" />
|
|
11
|
+
</UFormField>
|
|
12
|
+
|
|
13
|
+
<UFormField label="Slug" name="slug">
|
|
14
|
+
<UInput v-model="state.slug" placeholder="acme" class="w-full" />
|
|
15
|
+
</UFormField>
|
|
16
|
+
|
|
17
|
+
<UFormField label="Logo URL" name="logo">
|
|
18
|
+
<UInput v-model="state.logo" placeholder="https://example.com/logo.png" class="w-full" />
|
|
19
|
+
</UFormField>
|
|
20
|
+
|
|
21
|
+
<UButton
|
|
22
|
+
type="submit"
|
|
23
|
+
block
|
|
24
|
+
size="lg"
|
|
25
|
+
label="Create organization"
|
|
26
|
+
:loading="creating"
|
|
27
|
+
/>
|
|
28
|
+
</UForm>
|
|
29
|
+
</div>
|
|
30
|
+
</template>
|
|
31
|
+
|
|
32
|
+
<script setup>
|
|
33
|
+
import { z } from "zod"
|
|
34
|
+
|
|
35
|
+
const router = useRouter()
|
|
36
|
+
const { createOrganization, setActiveOrganization } = useOrganization()
|
|
37
|
+
|
|
38
|
+
const creating = ref(false)
|
|
39
|
+
|
|
40
|
+
const state = reactive({
|
|
41
|
+
name: '',
|
|
42
|
+
slug: '',
|
|
43
|
+
logo: '',
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
const schema = z.object({
|
|
47
|
+
name: z.string().min(1, 'Organization name is required'),
|
|
48
|
+
slug: z.string().min(1, 'Slug is required').regex(/^[a-z0-9-]+$/, 'Slug must be lowercase letters, numbers, and hyphens'),
|
|
49
|
+
logo: z.string().url().optional().or(z.literal('')),
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
async function handleCreate() {
|
|
53
|
+
creating.value = true
|
|
54
|
+
try {
|
|
55
|
+
const result = await createOrganization(state.name, state.slug || state.name.toLowerCase().replace(/\s+/g, '-'), state.logo || undefined)
|
|
56
|
+
if (result && !result.error) {
|
|
57
|
+
const slug = result?.slug || state.slug
|
|
58
|
+
await setActiveOrganization(result?.id)
|
|
59
|
+
router.push(`/org/${slug}`)
|
|
60
|
+
}
|
|
61
|
+
} finally {
|
|
62
|
+
creating.value = false
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
</script>
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="w-full space-y-6">
|
|
3
|
+
<div class="flex items-center justify-between">
|
|
4
|
+
<h2 class="text-lg font-semibold text-highlighted">Invitations</h2>
|
|
5
|
+
<UButton
|
|
6
|
+
v-if="isOrgAdmin"
|
|
7
|
+
size="sm"
|
|
8
|
+
to="invite"
|
|
9
|
+
label="Invite"
|
|
10
|
+
icon="i-lucide-plus"
|
|
11
|
+
/>
|
|
12
|
+
</div>
|
|
13
|
+
|
|
14
|
+
<div v-if="invitations.length === 0" class="text-center py-8 text-muted">
|
|
15
|
+
No pending invitations.
|
|
16
|
+
</div>
|
|
17
|
+
|
|
18
|
+
<div v-else class="divide-y divide-neutral-200 dark:divide-neutral-800">
|
|
19
|
+
<div
|
|
20
|
+
v-for="invitation in invitations"
|
|
21
|
+
:key="invitation.id"
|
|
22
|
+
class="flex items-center justify-between py-3"
|
|
23
|
+
>
|
|
24
|
+
<div>
|
|
25
|
+
<p class="text-sm font-medium text-highlighted">{{ invitation.email }}</p>
|
|
26
|
+
<p class="text-xs text-muted">
|
|
27
|
+
Role: {{ invitation.role }}
|
|
28
|
+
<span v-if="invitation.status">· {{ invitation.status }}</span>
|
|
29
|
+
</p>
|
|
30
|
+
</div>
|
|
31
|
+
|
|
32
|
+
<UButton
|
|
33
|
+
v-if="isOrgAdmin"
|
|
34
|
+
color="error"
|
|
35
|
+
variant="soft"
|
|
36
|
+
size="xs"
|
|
37
|
+
label="Cancel"
|
|
38
|
+
icon="i-lucide-x"
|
|
39
|
+
@click="cancelInvitation(invitation.id)"
|
|
40
|
+
/>
|
|
41
|
+
</div>
|
|
42
|
+
</div>
|
|
43
|
+
</div>
|
|
44
|
+
</template>
|
|
45
|
+
|
|
46
|
+
<script setup>
|
|
47
|
+
const { invitations, isOrgAdmin, cancelInvitation } = useOrganization()
|
|
48
|
+
</script>
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="w-full space-y-6">
|
|
3
|
+
<div class="text-center">
|
|
4
|
+
<h2 class="text-lg font-semibold text-highlighted">Invite member</h2>
|
|
5
|
+
<p class="text-sm text-muted mt-1">Invite a new member to this organization.</p>
|
|
6
|
+
</div>
|
|
7
|
+
|
|
8
|
+
<UForm :schema="schema" :state="state" class="space-y-4" @submit="handleInvite">
|
|
9
|
+
<UFormField label="Email" name="email">
|
|
10
|
+
<UInput v-model="state.email" type="email" placeholder="colleague@example.com" class="w-full" />
|
|
11
|
+
</UFormField>
|
|
12
|
+
|
|
13
|
+
<UFormField label="Role" name="role">
|
|
14
|
+
<USelect
|
|
15
|
+
v-model="state.role"
|
|
16
|
+
:items="roleOptions"
|
|
17
|
+
class="w-full"
|
|
18
|
+
/>
|
|
19
|
+
</UFormField>
|
|
20
|
+
|
|
21
|
+
<UButton
|
|
22
|
+
type="submit"
|
|
23
|
+
block
|
|
24
|
+
size="lg"
|
|
25
|
+
label="Send invitation"
|
|
26
|
+
:loading="inviting"
|
|
27
|
+
/>
|
|
28
|
+
</UForm>
|
|
29
|
+
</div>
|
|
30
|
+
</template>
|
|
31
|
+
|
|
32
|
+
<script setup>
|
|
33
|
+
import { z } from "zod"
|
|
34
|
+
|
|
35
|
+
const props = defineProps({
|
|
36
|
+
organization: {
|
|
37
|
+
type: Object,
|
|
38
|
+
required: true,
|
|
39
|
+
},
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
const router = useRouter()
|
|
43
|
+
const { inviteMember } = useOrganization()
|
|
44
|
+
|
|
45
|
+
const inviting = ref(false)
|
|
46
|
+
|
|
47
|
+
const state = reactive({
|
|
48
|
+
email: '',
|
|
49
|
+
role: 'member',
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
const roleOptions = [
|
|
53
|
+
{ label: 'Member', value: 'member' },
|
|
54
|
+
{ label: 'Admin', value: 'admin' },
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
const schema = z.object({
|
|
58
|
+
email: z.string().email('Please enter a valid email address'),
|
|
59
|
+
role: z.enum(['member', 'admin']),
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
async function handleInvite() {
|
|
63
|
+
inviting.value = true
|
|
64
|
+
try {
|
|
65
|
+
const result = await inviteMember(state.email, state.role, props.organization.id)
|
|
66
|
+
if (result && !result.error) {
|
|
67
|
+
router.push(`/org/${props.organization.slug}/invitations`)
|
|
68
|
+
}
|
|
69
|
+
} finally {
|
|
70
|
+
inviting.value = false
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
</script>
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="w-full space-y-6">
|
|
3
|
+
<div class="flex items-center justify-between">
|
|
4
|
+
<h2 class="text-lg font-semibold text-highlighted">Members</h2>
|
|
5
|
+
<UButton
|
|
6
|
+
v-if="isOrgAdmin"
|
|
7
|
+
size="sm"
|
|
8
|
+
to="invite"
|
|
9
|
+
label="Invite"
|
|
10
|
+
icon="i-lucide-user-plus"
|
|
11
|
+
/>
|
|
12
|
+
</div>
|
|
13
|
+
|
|
14
|
+
<div v-if="members.length === 0" class="text-center py-8 text-muted">
|
|
15
|
+
No members yet.
|
|
16
|
+
</div>
|
|
17
|
+
|
|
18
|
+
<div v-else class="divide-y divide-neutral-200 dark:divide-neutral-800">
|
|
19
|
+
<div
|
|
20
|
+
v-for="member in members"
|
|
21
|
+
:key="member.id"
|
|
22
|
+
class="flex items-center justify-between py-3"
|
|
23
|
+
>
|
|
24
|
+
<div class="flex items-center gap-3">
|
|
25
|
+
<div class="flex size-9 items-center justify-center rounded-full bg-primary/10 text-primary">
|
|
26
|
+
<UIcon name="i-lucide-user" class="size-4" />
|
|
27
|
+
</div>
|
|
28
|
+
<div>
|
|
29
|
+
<p class="text-sm font-medium text-highlighted">{{ member.name || member.email }}</p>
|
|
30
|
+
<p class="text-xs text-muted">{{ member.email }}</p>
|
|
31
|
+
</div>
|
|
32
|
+
</div>
|
|
33
|
+
|
|
34
|
+
<div class="flex items-center gap-2">
|
|
35
|
+
<UBadge
|
|
36
|
+
:label="member.role"
|
|
37
|
+
:color="roleColor(member.role)"
|
|
38
|
+
size="sm"
|
|
39
|
+
variant="soft"
|
|
40
|
+
/>
|
|
41
|
+
<UDropdownMenu
|
|
42
|
+
v-if="isOrgAdmin && !isCurrentUser(member)"
|
|
43
|
+
:items="memberActions(member)"
|
|
44
|
+
>
|
|
45
|
+
<UButton
|
|
46
|
+
color="neutral"
|
|
47
|
+
variant="ghost"
|
|
48
|
+
size="xs"
|
|
49
|
+
icon="i-lucide-ellipsis-vertical"
|
|
50
|
+
/>
|
|
51
|
+
</UDropdownMenu>
|
|
52
|
+
</div>
|
|
53
|
+
</div>
|
|
54
|
+
</div>
|
|
55
|
+
</div>
|
|
56
|
+
</template>
|
|
57
|
+
|
|
58
|
+
<script setup>
|
|
59
|
+
const props = defineProps({
|
|
60
|
+
organization: {
|
|
61
|
+
type: Object,
|
|
62
|
+
required: true,
|
|
63
|
+
},
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
const { user } = useXAuth()
|
|
67
|
+
const { members, isOrgAdmin, isOrgOwner, updateMemberRole, removeMember } = useOrganization()
|
|
68
|
+
|
|
69
|
+
const roleColor = (role) => {
|
|
70
|
+
const roles = role.split(',')
|
|
71
|
+
if (roles.includes('owner')) return 'primary'
|
|
72
|
+
if (roles.includes('admin')) return 'warning'
|
|
73
|
+
return 'neutral'
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const isCurrentUser = (member) => member.userId === user.value?.id
|
|
77
|
+
|
|
78
|
+
const memberActions = (member) => [
|
|
79
|
+
...(isOrgOwner.value
|
|
80
|
+
? [{
|
|
81
|
+
label: 'Make owner',
|
|
82
|
+
icon: 'i-lucide-crown',
|
|
83
|
+
onSelect: () => updateMemberRole(member.id, 'owner'),
|
|
84
|
+
}]
|
|
85
|
+
: []),
|
|
86
|
+
{
|
|
87
|
+
label: 'Make admin',
|
|
88
|
+
icon: 'i-lucide-shield',
|
|
89
|
+
onSelect: () => updateMemberRole(member.id, 'admin'),
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
label: 'Make member',
|
|
93
|
+
icon: 'i-lucide-user',
|
|
94
|
+
onSelect: () => updateMemberRole(member.id, 'member'),
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
label: 'Remove',
|
|
98
|
+
icon: 'i-lucide-trash',
|
|
99
|
+
color: 'error',
|
|
100
|
+
onSelect: () => removeMember(member.id, props.organization.id),
|
|
101
|
+
},
|
|
102
|
+
]
|
|
103
|
+
</script>
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="w-full space-y-6">
|
|
3
|
+
<div class="flex items-center justify-between">
|
|
4
|
+
<h2 class="text-lg font-semibold text-highlighted">Settings</h2>
|
|
5
|
+
<UButton
|
|
6
|
+
v-if="isOrgOwner"
|
|
7
|
+
color="error"
|
|
8
|
+
variant="soft"
|
|
9
|
+
size="sm"
|
|
10
|
+
label="Delete"
|
|
11
|
+
icon="i-lucide-trash"
|
|
12
|
+
@click="handleDelete"
|
|
13
|
+
/>
|
|
14
|
+
</div>
|
|
15
|
+
|
|
16
|
+
<UForm :schema="schema" :state="state" class="space-y-4" @submit="handleUpdate">
|
|
17
|
+
<UFormField label="Name" name="name">
|
|
18
|
+
<UInput v-model="state.name" class="w-full" />
|
|
19
|
+
</UFormField>
|
|
20
|
+
|
|
21
|
+
<UFormField label="Slug" name="slug">
|
|
22
|
+
<UInput v-model="state.slug" class="w-full" />
|
|
23
|
+
</UFormField>
|
|
24
|
+
|
|
25
|
+
<UFormField label="Logo URL" name="logo">
|
|
26
|
+
<UInput v-model="state.logo" class="w-full" />
|
|
27
|
+
</UFormField>
|
|
28
|
+
|
|
29
|
+
<UButton
|
|
30
|
+
type="submit"
|
|
31
|
+
size="sm"
|
|
32
|
+
label="Save changes"
|
|
33
|
+
:loading="updating"
|
|
34
|
+
/>
|
|
35
|
+
</UForm>
|
|
36
|
+
</div>
|
|
37
|
+
</template>
|
|
38
|
+
|
|
39
|
+
<script setup>
|
|
40
|
+
import { z } from "zod"
|
|
41
|
+
|
|
42
|
+
const props = defineProps({
|
|
43
|
+
organization: {
|
|
44
|
+
type: Object,
|
|
45
|
+
required: true,
|
|
46
|
+
},
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
const { updateOrganization, deleteOrganization, isOrgOwner } = useOrganization()
|
|
50
|
+
|
|
51
|
+
const updating = ref(false)
|
|
52
|
+
|
|
53
|
+
const state = reactive({
|
|
54
|
+
name: props.organization.name || '',
|
|
55
|
+
slug: props.organization.slug || '',
|
|
56
|
+
logo: props.organization.logo || '',
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
const schema = z.object({
|
|
60
|
+
name: z.string().min(1, 'Name is required'),
|
|
61
|
+
slug: z.string().min(1, 'Slug is required').regex(/^[a-z0-9-]+$/, 'Slug must be lowercase letters, numbers, and hyphens'),
|
|
62
|
+
logo: z.string().url().optional().or(z.literal('')),
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
async function handleUpdate() {
|
|
66
|
+
updating.value = true
|
|
67
|
+
try {
|
|
68
|
+
await updateOrganization({
|
|
69
|
+
organizationId: props.organization.id,
|
|
70
|
+
name: state.name,
|
|
71
|
+
slug: state.slug,
|
|
72
|
+
logo: state.logo || undefined,
|
|
73
|
+
})
|
|
74
|
+
} finally {
|
|
75
|
+
updating.value = false
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function handleDelete() {
|
|
80
|
+
if (!confirm('Are you sure you want to delete this organization? This action cannot be undone.')) {
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
await deleteOrganization(props.organization.id)
|
|
84
|
+
}
|
|
85
|
+
</script>
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="w-full space-y-4">
|
|
3
|
+
<div class="flex items-center justify-between">
|
|
4
|
+
<h2 class="text-lg font-semibold text-highlighted">Organizations</h2>
|
|
5
|
+
<UButton
|
|
6
|
+
v-if="canCreateOrganization"
|
|
7
|
+
size="sm"
|
|
8
|
+
to="/org/create"
|
|
9
|
+
label="New"
|
|
10
|
+
icon="i-lucide-plus"
|
|
11
|
+
/>
|
|
12
|
+
</div>
|
|
13
|
+
|
|
14
|
+
<div v-if="organizationsLoading" class="flex items-center justify-center py-8">
|
|
15
|
+
<UIcon name="i-lucide-loader-2" class="size-6 animate-spin text-primary" />
|
|
16
|
+
</div>
|
|
17
|
+
|
|
18
|
+
<div v-else-if="organizations.length === 0" class="text-center py-8">
|
|
19
|
+
<div class="mx-auto mb-4 flex size-14 items-center justify-center rounded-full bg-primary/10 ring-1 ring-primary/20">
|
|
20
|
+
<UIcon name="i-lucide-building-2" class="size-7 text-primary" />
|
|
21
|
+
</div>
|
|
22
|
+
<h3 class="text-base font-semibold text-highlighted">No organizations yet</h3>
|
|
23
|
+
<p class="text-sm text-muted mt-1">Create an organization to get started.</p>
|
|
24
|
+
<UButton
|
|
25
|
+
v-if="canCreateOrganization"
|
|
26
|
+
class="mt-4"
|
|
27
|
+
size="sm"
|
|
28
|
+
to="/org/create"
|
|
29
|
+
label="Create organization"
|
|
30
|
+
/>
|
|
31
|
+
</div>
|
|
32
|
+
|
|
33
|
+
<div v-else class="space-y-2">
|
|
34
|
+
<NuxtLink
|
|
35
|
+
v-for="org in organizations"
|
|
36
|
+
:key="org.id"
|
|
37
|
+
:to="`/org/${org.slug}`"
|
|
38
|
+
class="flex items-center gap-3 p-3 rounded-lg border border-neutral-200 dark:border-neutral-800 hover:bg-neutral-50 dark:hover:bg-neutral-900 transition-colors"
|
|
39
|
+
:class="{ 'bg-primary/5 border-primary/20 dark:border-primary/20': activeOrganization?.id === org.id }"
|
|
40
|
+
>
|
|
41
|
+
<div class="flex size-10 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
|
42
|
+
<img v-if="org.logo" :src="org.logo" alt="" class="size-10 rounded-lg object-cover" />
|
|
43
|
+
<UIcon v-else name="i-lucide-building-2" class="size-5" />
|
|
44
|
+
</div>
|
|
45
|
+
<div class="flex-1 min-w-0">
|
|
46
|
+
<p class="font-medium text-highlighted truncate">{{ org.name }}</p>
|
|
47
|
+
<p class="text-xs text-muted">@{{ org.slug }}</p>
|
|
48
|
+
</div>
|
|
49
|
+
<UIcon
|
|
50
|
+
v-if="activeOrganization?.id === org.id"
|
|
51
|
+
name="i-lucide-check"
|
|
52
|
+
class="size-5 text-primary"
|
|
53
|
+
/>
|
|
54
|
+
</NuxtLink>
|
|
55
|
+
</div>
|
|
56
|
+
</div>
|
|
57
|
+
</template>
|
|
58
|
+
|
|
59
|
+
<script setup>
|
|
60
|
+
const { organizations, organizationsLoading, activeOrganization, canCreateOrganization } = useOrganization()
|
|
61
|
+
</script>
|