diff --git a/web/src/lib/google.ts b/web/src/lib/google.ts new file mode 100644 index 0000000..cdbdc60 --- /dev/null +++ b/web/src/lib/google.ts @@ -0,0 +1,27 @@ +import { useQuery } from "@tanstack/react-query" + +import { api } from "./api" + +// Mirror of google/model/group_binding.go::GroupGoogleBinding. A 1:1 mapping of +// a Sentinel group to the Google Group its membership is mirrored into. +export type GroupGoogleBinding = { + id: string + group_id: string + google_group_email: string + created_at: string +} + +// useGroupGoogleBinding returns the single binding for a group, or null. The +// list endpoint returns an array (0 or 1 rows) since the mapping is 1:1. +export function useGroupGoogleBinding(groupID: string) { + return useQuery({ + queryKey: ["group", groupID, "google-binding"], + queryFn: async () => { + const res = await api.get(`/google/group-bindings`, { + params: { group_id: groupID }, + }) + return res.data[0] ?? null + }, + enabled: !!groupID, + }) +} diff --git a/web/src/pages/groups/GroupEditPage.tsx b/web/src/pages/groups/GroupEditPage.tsx index d7739cb..b9dda48 100644 --- a/web/src/pages/groups/GroupEditPage.tsx +++ b/web/src/pages/groups/GroupEditPage.tsx @@ -1,5 +1,5 @@ import { useQuery, useQueryClient } from "@tanstack/react-query" -import { ArrowLeft, Bot, Plus, Sparkles, Trash2, X } from "lucide-react" +import { ArrowLeft, Bot, Mail, Plus, Sparkles, Trash2, X } from "lucide-react" import { useEffect, useMemo, useState } from "react" import { Link, useNavigate, useParams } from "react-router-dom" import { toast } from "sonner" @@ -9,6 +9,7 @@ import { PageContainer } from "@/components/PageContainer" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Input } from "@/components/ui/input" import { Dialog, DialogContent, @@ -38,6 +39,7 @@ import { useGroupDiscordBindings, type GroupDiscordRoleBinding, } from "@/lib/discord" +import { useGroupGoogleBinding } from "@/lib/google" import type { Group, GroupMember, GroupOwner, GroupSource } from "@/lib/groups" import { DiscordRolePickerDialog } from "./DiscordRolePickerDialog" @@ -226,6 +228,48 @@ function ConditionalSyncCard({ ) } +function GoogleSyncCard({ + email, + onChange, + onSyncNow, + syncing, +}: { + email: string + onChange: (email: string) => void + onSyncNow: () => void + syncing: boolean +}) { + return ( + + + + + Google Group sync + + + Mirror this group's members into a Google Group. Everyone in the group is + synced as a MEMBER; owners and managers added directly in Google are left + untouched. Leave blank to disable. Changes apply on Save. + + + + onChange(e.target.value)} + /> +
+ +
+
+
+ ) +} + function LinkedApplicationsCard({ links, allApps, @@ -368,6 +412,7 @@ export default function GroupEditPage() { const bindingsQuery = useGroupDiscordBindings(id ?? "") const conditionalBindingsQuery = useGroupConditionalBindings(id ?? "") + const googleBindingQuery = useGroupGoogleBinding(id ?? "") // All groups, used by the conditional editor to resolve required_group_ids // → names for the chips and to feed the picker dialog. Cheap query for @@ -411,6 +456,12 @@ export default function GroupEditPage() { const [values, setValues] = useState(null) const [submitting, setSubmitting] = useState(false) const [deleting, setDeleting] = useState(false) + // Google Group binding (1:1). Staged like the rest of the page — applied on + // Save by diffing against the server binding. Null until the query settles so + // we don't briefly show an empty field over an existing binding. + const [googleEmail, setGoogleEmail] = useState("") + const [googleEmailInitialized, setGoogleEmailInitialized] = useState(false) + const [syncingGoogle, setSyncingGoogle] = useState(false) const [confirmOpen, setConfirmOpen] = useState(false) const [cascadeConfirmOpen, setCascadeConfirmOpen] = useState(false) // Pending binding state — staged changes are applied to the server in @@ -509,6 +560,28 @@ export default function GroupEditPage() { } }, [query.data, values]) + useEffect(() => { + if (!googleBindingQuery.isLoading && !googleEmailInitialized) { + setGoogleEmail(googleBindingQuery.data?.google_group_email ?? "") + setGoogleEmailInitialized(true) + } + }, [googleBindingQuery.isLoading, googleBindingQuery.data, googleEmailInitialized]) + + async function handleSyncGoogleNow() { + setSyncingGoogle(true) + try { + await api.post("/google/reconcile") + toast.success("Google sync triggered") + } catch (err: unknown) { + const message = + (err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? + "Couldn't trigger Google sync." + toast.error(message) + } finally { + setSyncingGoogle(false) + } + } + useEffect(() => { if (linkedAppsQuery.data && !appLinksInitialized) { const m = new Map() @@ -590,6 +663,27 @@ export default function GroupEditPage() { }) } } + // Google Group binding is 1:1, so diff the input against the server + // binding: clear/replace deletes the old row, a non-empty value upserts. + // Not gated on allowed_sources — Google is an outbound projection, not a + // membership source. + const serverGoogleBinding = googleBindingQuery.data ?? null + const desiredGoogleEmail = googleEmail.trim() + const currentGoogleEmail = serverGoogleBinding?.google_group_email ?? "" + if (desiredGoogleEmail !== currentGoogleEmail) { + if (serverGoogleBinding) { + await api.delete(`/google/group-bindings/${serverGoogleBinding.id}`, { + params: { group_id: id }, + }) + } + if (desiredGoogleEmail) { + await api.post(`/google/group-bindings`, { + group_id: id, + google_group_email: desiredGoogleEmail, + }) + } + } + // Diff application links against the server state. POST is upsert, // so we send any link whose required flag differs (or doesn't exist // yet); DELETE anything the server has that's no longer in our state. @@ -620,6 +714,7 @@ export default function GroupEditPage() { qc.invalidateQueries({ queryKey: ["group", id] }) qc.invalidateQueries({ queryKey: ["group", id, "members"] }) qc.invalidateQueries({ queryKey: ["group", id, "discord-bindings"] }) + qc.invalidateQueries({ queryKey: ["group", id, "google-binding"] }) qc.invalidateQueries({ queryKey: ["group", id, "applications"] }) toast.success("Group updated") navigate(`/groups/${id}`) @@ -685,6 +780,7 @@ export default function GroupEditPage() { membersQuery.isLoading || bindingsQuery.isLoading || conditionalBindingsQuery.isLoading || + googleBindingQuery.isLoading || adminsLoading ) { return ( @@ -795,6 +891,13 @@ export default function GroupEditPage() { /> )} + +