feat(web): hq-sourced members are read-only here
Server Deploy / deploy (push) Successful in 2m56s

The lock is a courtesy — the API answers 409 either way. NEXT_PUBLIC_HQ_URL
defaults empty so a self-hosted install shows a plain label rather than a
link to a portal that does not serve them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mrhid6
2026-07-26 16:42:47 +01:00
co-authored by Claude Opus 5
parent 2d12669f9b
commit 7b077905e2
4 changed files with 44 additions and 13 deletions
+1
View File
@@ -33,6 +33,7 @@ jobs:
IMAGE="${{ vars.DOCKER_HOST }}/${{ github.repository_owner }}/vantage/web:latest"
docker build \
--build-arg NEXT_PUBLIC_API_URL="${{ vars.API_URL }}" \
--build-arg NEXT_PUBLIC_HQ_URL="${{ vars.HQ_URL }}" \
-t "$IMAGE" \
-f web/Dockerfile web/
docker push "$IMAGE"
+6
View File
@@ -17,6 +17,12 @@ COPY . .
ARG NEXT_PUBLIC_API_URL=http://localhost:8080
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
# Empty default on purpose: a self-hosted install has no HQ portal, and the
# "Managed in Vantage HQ" label falls back to plain text rather than linking
# somewhere that does not serve them.
ARG NEXT_PUBLIC_HQ_URL=
ENV NEXT_PUBLIC_HQ_URL=$NEXT_PUBLIC_HQ_URL
RUN npm run build
# Runtime stage
+33 -12
View File
@@ -69,6 +69,7 @@ function MembersCard() {
const isOwner = user?.role === "owner";
const assignableRoles = isOwner ? ROLES : ROLES.filter((r) => r !== "owner");
const hqUrl = process.env.NEXT_PUBLIC_HQ_URL ?? "";
return (
<Card>
@@ -113,7 +114,10 @@ function MembersCard() {
{users.map((u: InstanceUser) => {
const isSelf = u.user_id === user?.user_id;
const locked = isSelf || (u.role === "owner" && !isOwner);
const managedByHQ = u.auth_source === "hq";
// Locked here is a courtesy: the API returns 409 for an hq-sourced
// role change or deletion whether or not this select is rendered.
const locked = isSelf || managedByHQ || (u.role === "owner" && !isOwner);
return (
<Tr key={u.user_id}>
<Td>
@@ -138,22 +142,39 @@ function MembersCard() {
)}
</Td>
<Td>
<Badge variant="neutral">{u.auth_source === "oidc" ? "SSO" : "Password"}</Badge>
<Badge variant="neutral">
{u.auth_source === "oidc" ? "SSO" : u.auth_source === "hq" ? "Vantage HQ" : "Password"}
</Badge>
</Td>
<Td className="text-text-secondary">
{u.last_login ? new Date(u.last_login).toLocaleString() : "Never"}
</Td>
<Td className="text-right">
{!locked && (
<Button
variant="ghost"
size="sm"
onClick={() => {
if (confirm(`Remove ${u.email} from this instance?`)) removeUser(u.user_id);
}}
>
Remove
</Button>
{managedByHQ ? (
hqUrl ? (
<a
href={hqUrl}
target="_blank"
rel="noreferrer"
className="text-xs text-text-secondary underline"
>
Managed in Vantage HQ
</a>
) : (
<span className="text-xs text-text-tertiary">Managed in Vantage HQ</span>
)
) : (
!locked && (
<Button
variant="ghost"
size="sm"
onClick={() => {
if (confirm(`Remove ${u.email} from this instance?`)) removeUser(u.user_id);
}}
>
Remove
</Button>
)
)}
</Td>
</Tr>
+4 -1
View File
@@ -335,7 +335,10 @@ export interface InstanceUser {
instance_id: string;
email: string;
role: Role;
auth_source: "local" | "oidc";
// "hq" means the row was projected from a Vantage HQ account. Its role,
// password and existence belong to HQ; this instance refuses to change them.
auth_source: "local" | "oidc" | "hq";
hq_user_id?: string;
created_at: string;
last_login?: string;
}