227 lines
9.0 KiB
Vue
227 lines
9.0 KiB
Vue
<script setup lang="ts">
|
|
import InputError from "@/components/InputError.vue";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import AppLayout from "@/layouts/AppLayout.vue";
|
|
import { Head, router, useForm } from "@inertiajs/vue3";
|
|
import { Trash2Icon } from "lucide-vue-next";
|
|
|
|
const props = defineProps<{
|
|
organisation: Record<string, any>;
|
|
roles: string[];
|
|
}>();
|
|
|
|
const inviteForm = useForm({
|
|
email: "",
|
|
role: "member",
|
|
});
|
|
|
|
const updateRole = (member: Record<string, any>, role: string): void => {
|
|
router.put(
|
|
route("organisation-members.update", {
|
|
organisation: props.organisation.id,
|
|
member: member.id,
|
|
}),
|
|
{ role },
|
|
{ preserveScroll: true },
|
|
);
|
|
};
|
|
|
|
const updateInvitationRole = (invitation: Record<string, any>, role: string): void => {
|
|
router.put(
|
|
route("organisation-invitations.update", {
|
|
organisation: props.organisation.id,
|
|
invitation: invitation.id,
|
|
}),
|
|
{ role },
|
|
{ preserveScroll: true },
|
|
);
|
|
};
|
|
|
|
const removeMember = (member: Record<string, any>): void => {
|
|
if (!window.confirm(`Remove ${member.name} from ${props.organisation.name}?`)) {
|
|
return;
|
|
}
|
|
|
|
router.delete(
|
|
route("organisation-members.destroy", {
|
|
organisation: props.organisation.id,
|
|
member: member.id,
|
|
}),
|
|
{ preserveScroll: true },
|
|
);
|
|
};
|
|
|
|
const cancelInvitation = (invitation: Record<string, any>): void => {
|
|
if (!window.confirm(`Cancel invitation for ${invitation.email}?`)) {
|
|
return;
|
|
}
|
|
|
|
router.delete(
|
|
route("organisation-invitations.destroy", {
|
|
organisation: props.organisation.id,
|
|
invitation: invitation.id,
|
|
}),
|
|
{ preserveScroll: true },
|
|
);
|
|
};
|
|
</script>
|
|
|
|
<template>
|
|
<Head :title="`${organisation.name} Members`" />
|
|
|
|
<AppLayout
|
|
:breadcrumbs="[
|
|
{
|
|
title: organisation.name,
|
|
href: route('organisations.show', { organisation: organisation.id }),
|
|
},
|
|
{ title: 'Members' },
|
|
]"
|
|
>
|
|
<div class="flex h-full flex-1 flex-col gap-4 p-4">
|
|
<div>
|
|
<h2 class="text-3xl font-bold tracking-tight">Members</h2>
|
|
<p class="mt-1 text-sm text-muted-foreground">
|
|
Invite teammates, change roles, and remove access.
|
|
</p>
|
|
</div>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Invite Member</CardTitle>
|
|
<CardDescription>
|
|
Existing users are added immediately. New emails remain pending until accepted.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<form
|
|
class="grid gap-4 md:grid-cols-[1fr_180px_auto]"
|
|
@submit.prevent="
|
|
inviteForm.post(
|
|
route('organisation-members.store', {
|
|
organisation: organisation.id,
|
|
}),
|
|
{ preserveScroll: true, onSuccess: () => inviteForm.reset('email') },
|
|
)
|
|
"
|
|
>
|
|
<div class="grid gap-2">
|
|
<Label for="email">Email</Label>
|
|
<Input id="email" v-model="inviteForm.email" type="email" required />
|
|
<InputError :message="inviteForm.errors.email" />
|
|
</div>
|
|
<div class="grid gap-2">
|
|
<Label for="role">Role</Label>
|
|
<select
|
|
id="role"
|
|
v-model="inviteForm.role"
|
|
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm"
|
|
>
|
|
<option v-for="role in roles" :key="role" :value="role">
|
|
{{ role }}
|
|
</option>
|
|
</select>
|
|
<InputError :message="inviteForm.errors.role" />
|
|
</div>
|
|
<div class="flex items-end">
|
|
<Button type="submit" :disabled="inviteForm.processing">Invite</Button>
|
|
</div>
|
|
</form>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Pending Invitations</CardTitle>
|
|
<CardDescription>
|
|
{{ organisation.invitations.length }} pending invitations
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent class="grid gap-2">
|
|
<div
|
|
v-for="invitation in organisation.invitations"
|
|
:key="invitation.id"
|
|
class="flex flex-wrap items-center gap-3 rounded-md border p-3 text-sm"
|
|
>
|
|
<div class="min-w-0 flex-1">
|
|
<div class="font-medium">{{ invitation.email }}</div>
|
|
<div class="text-muted-foreground">
|
|
Invited by
|
|
{{ invitation.invited_by?.name ?? "Keystone" }}
|
|
<span v-if="invitation.expires_at"> · expires {{ invitation.expires_at }}</span>
|
|
</div>
|
|
</div>
|
|
<select
|
|
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm"
|
|
:value="invitation.role ?? 'member'"
|
|
@change="
|
|
updateInvitationRole(
|
|
invitation,
|
|
($event.target as HTMLSelectElement).value,
|
|
)
|
|
"
|
|
>
|
|
<option v-for="role in roles" :key="role" :value="role">
|
|
{{ role }}
|
|
</option>
|
|
</select>
|
|
<Button
|
|
size="iconxs"
|
|
variant="ghost"
|
|
:aria-label="`Cancel invitation for ${invitation.email}`"
|
|
@click="cancelInvitation(invitation)"
|
|
>
|
|
<Trash2Icon class="size-3" />
|
|
</Button>
|
|
</div>
|
|
<div
|
|
v-if="organisation.invitations.length === 0"
|
|
class="rounded-md border border-dashed p-3 text-sm text-muted-foreground"
|
|
>
|
|
No pending invitations.
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Roster</CardTitle>
|
|
<CardDescription>{{ organisation.members.length }} members</CardDescription>
|
|
</CardHeader>
|
|
<CardContent class="grid gap-2">
|
|
<div
|
|
v-for="member in organisation.members"
|
|
:key="member.id"
|
|
class="flex flex-wrap items-center gap-3 rounded-md border p-3 text-sm"
|
|
>
|
|
<div class="min-w-0 flex-1">
|
|
<div class="font-medium">{{ member.name }}</div>
|
|
<div class="text-muted-foreground">{{ member.email }}</div>
|
|
</div>
|
|
<select
|
|
class="h-9 rounded-md border border-input bg-transparent px-3 text-sm"
|
|
:value="member.membership?.role ?? 'member'"
|
|
@change="updateRole(member, ($event.target as HTMLSelectElement).value)"
|
|
>
|
|
<option v-for="role in roles" :key="role" :value="role">
|
|
{{ role }}
|
|
</option>
|
|
</select>
|
|
<Button
|
|
size="iconxs"
|
|
variant="ghost"
|
|
:disabled="member.id === organisation.owner_id"
|
|
@click="removeMember(member)"
|
|
>
|
|
<Trash2Icon class="size-3" />
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</AppLayout>
|
|
</template>
|