wowowowowo
Some checks failed
CI / Lint (push) Failing after 22s
CI / Tests (push) Failing after 33s

This commit is contained in:
2026-05-28 15:15:41 +01:00
parent 8f603122e2
commit 5b977c1f41
129 changed files with 9943 additions and 722 deletions

View File

@@ -0,0 +1,226 @@
<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>