Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .githooks/pre-push
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,19 @@
# commit names a ticket that is still in progress or under review
# (commit -> review deletes done tickets -> push). See tools/quality/src/cli.mjs.
# Emergency bypass (avoid): git push --no-verify

# git passes one line per ref: <local ref> <local sha> <remote ref> <remote sha>.
# A local sha of all zeros means "delete the remote ref": no commits leave the
# machine, so there is nothing to check. Run the gate only if a ref sends commits.
sends_commits=false
while read -r _local_ref local_sha _remote_ref _remote_sha; do
case "$local_sha" in
*[!0]*) sends_commits=true ;;
esac
done

if [ "$sends_commits" = false ]; then
exit 0
fi

exec pnpm -s quality --push
45 changes: 45 additions & 0 deletions .github/rulesets/main.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
{
"name": "main",
"target": "branch",
"enforcement": "active",
"conditions": {
"ref_name": {
"include": ["~DEFAULT_BRANCH"],
"exclude": []
}
},
"rules": [
{ "type": "deletion" },
{ "type": "non_fast_forward" },
{ "type": "required_linear_history" },
{
"type": "pull_request",
"parameters": {
"required_approving_review_count": 0,
"dismiss_stale_reviews_on_push": true,
"require_code_owner_review": false,
"require_last_push_approval": false,
"required_review_thread_resolution": true,
"allowed_merge_methods": ["squash"]
}
},
{
"type": "required_status_checks",
"parameters": {
"strict_required_status_checks_policy": true,
"do_not_enforce_on_create": false,
"required_status_checks": [
{ "context": "Quality + integration", "integration_id": 15368 },
{ "context": "Playwright e2e", "integration_id": 15368 }
]
}
}
],
"bypass_actors": [
{
"actor_id": 5,
"actor_type": "RepositoryRole",
"bypass_mode": "pull_request"
}
]
}
196 changes: 117 additions & 79 deletions BACKLOG.md

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,8 @@ On every push to `main` and every pull request, GitHub Actions runs:
1. **Quality + integration** — `pnpm quality` (backlog rules, `nx sync:check`, typecheck/lint/test, knip, unused translation keys) against Postgres 16 with migrations applied. The commit gate (`--gate`) is **not** used in CI; review state is local.
2. **Playwright e2e** — builds the client and API, starts Mailpit + MinIO, seeds Acme, and runs `@work-hub/client-e2e` against the built apps.

`main` is protected by [`.github/rulesets/main.json`](.github/rulesets/main.json): changes arrive only by pull request, both checks must be green on an up-to-date branch, squash merge only. To apply or update it: Settings → Rules → Rulesets → Import a ruleset.

Local e2e: start `pnpm dev`, then `pnpm nx e2e @work-hub/client-e2e` (one worker; signs in once per seed role via the API).

## Status
Expand Down
10 changes: 9 additions & 1 deletion apps/client-e2e/src/app-shell.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ test.describe('app shell (WH-053) — owner', () => {
{ path: '/en/s/acme/holidays', heading: 'Holidays' },
{ path: '/en/s/acme/documents', heading: 'Documents' },
{ path: '/en/s/acme/approvals', heading: 'Approvals' },
{ path: '/en/s/acme/roles', heading: 'Roles and permissions' },
{ path: '/en/s/acme/leave-policies', heading: 'Leave policies' },
] as const;

Expand All @@ -40,6 +39,15 @@ test.describe('app shell (WH-053) — owner', () => {
).toBeVisible();
}

// WH-011: Roles is a real page (not coming soon).
await page.goto('/en/s/acme/roles');
await expect(
page.getByRole('heading', { name: 'Roles and permissions' })
).toBeVisible();
await expect(
page.getByText("We're building this. It'll be ready soon.")
).toHaveCount(0);

await page.goto('/en/s/acme/team');
await expect(page.getByRole('heading', { name: 'Team' })).toBeVisible();
await expect(
Expand Down
6 changes: 6 additions & 0 deletions apps/client/specs/members-api.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ describe('listSpaceMembersResponseSchema', () => {
role: {
id: '01936b3c-0000-7000-8000-000000000003',
name: 'Member',
permissions: [
'members.read',
'calendar.read',
'requests.create',
'profile.write',
],
},
membershipStatus: 'active',
createdAt: '2026-10-17T12:00:00.000Z',
Expand Down
3 changes: 2 additions & 1 deletion apps/client/specs/space-nav.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,8 +86,9 @@ describe('activeSpaceNavId', () => {
});

describe('SPACE_NAV_ITEMS readiness', () => {
it('marks Team as ready', () => {
it('marks Team and Roles as ready', () => {
expect(SPACE_NAV_ITEMS.find((i) => i.id === 'team')?.status).toBe('ready');
expect(SPACE_NAV_ITEMS.find((i) => i.id === 'roles')?.status).toBe('ready');
});
});

Expand Down
11 changes: 2 additions & 9 deletions apps/client/src/app/[locale]/(app)/s/[slug]/roles/page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import type { Metadata } from 'next';
import { PERMISSIONS } from '@work-hub/constants';
import { getTranslations, setRequestLocale } from 'next-intl/server';
import { SpaceFeaturePage } from '@/components/shell/space-feature-page';
import { SpaceRoles } from '@/components/spaces/space-roles';

type PageProps = {
params: Promise<{ locale: string; slug: string }>;
Expand All @@ -20,11 +19,5 @@ export async function generateMetadata({
export default async function Page({ params }: PageProps) {
const { locale } = await params;
setRequestLocale(locale);
return (
<SpaceFeaturePage
feature="roles"
icon="shield"
permission={PERMISSIONS.ROLES_WRITE}
/>
);
return <SpaceRoles />;
}
2 changes: 1 addition & 1 deletion apps/client/src/app/[locale]/(auth)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export default function AuthLayout({ children }: { children: ReactNode }) {
className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_20%_0%,oklch(0.92_0.035_145),transparent_45%),radial-gradient(circle_at_90%_100%,oklch(0.94_0.02_100),transparent_40%)]"
/>
<AppHeader />
<div className="relative z-10 flex flex-1 items-start justify-center px-6 pb-16 sm:items-center sm:px-8">
<div className="relative z-10 flex flex-1 items-start justify-center px-(--app-scroll-pad-x) pt-8 pb-(--app-scroll-end) sm:items-center">
<div className="w-full max-w-md animate-rise rounded-2xl border border-border/80 bg-background/90 p-6 shadow-sm sm:p-8">
{children}
</div>
Expand Down
28 changes: 28 additions & 0 deletions apps/client/src/app/global.css
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,13 @@
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--radius: 0.625rem;
/*
* Clearance below scrollable page content (AppMain, auth, etc.).
* Prefer this token over ad-hoc pb-* so every surface keeps the same end gap.
*/
--app-scroll-end: max(7.5rem, calc(5rem + env(safe-area-inset-bottom, 0px)));
--app-scroll-pad-x: 1rem;
--app-scroll-pad-top: 2rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
Expand All @@ -252,6 +259,14 @@
--sidebar-ring: oklch(0.708 0 0);
}

@media (min-width: 640px) {
:root {
--app-scroll-end: max(9rem, calc(6rem + env(safe-area-inset-bottom, 0px)));
--app-scroll-pad-x: 1.5rem;
--app-scroll-pad-top: 2.5rem;
}
}

.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
Expand Down Expand Up @@ -309,6 +324,19 @@
}
}

/**
* Scrollable page column: horizontal inset + end clearance.
* Top inset lives on AppMain’s inner wrapper (not here) so `position: sticky;
* top: 0` can sit flush with the scrollport — padding on the overflow element
* leaves a gap where scrolled content stays visible above sticky bars.
* Pair with a trailing `h-(--app-scroll-end)` spacer when the region is flex+overflow
* so the gap survives flex min-height quirks.
*/
@utility app-scroll-region {
padding-inline: var(--app-scroll-pad-x);
scroll-padding-bottom: var(--app-scroll-end);
}

/* Animation delays — Tailwind `delay-*` is transition-delay only. */
@utility animate-delay-100 {
animation-delay: 100ms;
Expand Down
132 changes: 78 additions & 54 deletions apps/client/src/components/invites/accept-invite.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
'use client';

import { useLocale, useTranslations } from 'next-intl';
import { useEffect, useRef } from 'react';
import { PageLoader } from '@/components/brand/logo-loader';
import { Button } from '@/components/ui/button';
import { buttonVariants } from '@/components/ui/button-variants';
import { useAuth } from '@/hooks/use-auth';
import { useAcceptInvite, useInvitePreview } from '@/hooks/use-invites';
import { useRoleLabel } from '@/hooks/use-role-label';
import { Link, useRouter } from '@/i18n/navigation';
import { ApiError } from '@/lib/api/fetch';
import { ROUTES } from '@/lib/routes';
Expand All @@ -31,51 +31,23 @@ type AcceptInviteProps = {
token: string;
};

/**
* Invite landing: preview the workspace, then the user must approve before joining.
* Signed-out users sign in / sign up first; signed-in users confirm explicitly (no auto-accept).
*/
export function AcceptInvite({ token }: AcceptInviteProps) {
const t = useTranslations('InviteAccept');
const locale = useLocale();
const router = useRouter();
const { isSignedIn, isPending: authPending } = useAuth();
const preview = useInvitePreview(token);
const accept = useAcceptInvite();
const autoTried = useRef(false);
const roleLabel = useRoleLabel(preview.data?.roleName ?? '');

const signedIn = isSignedIn;
const nextPath = `/invite/${encodeURIComponent(token)}`;
const signInHref = `${ROUTES.signIn}?next=${encodeURIComponent(nextPath)}`;
const signUpHref = `${ROUTES.signUp}?next=${encodeURIComponent(nextPath)}`;

const acceptMutate = accept.mutateAsync;

useEffect(() => {
if (
!signedIn ||
!preview.data ||
accept.isSuccess ||
accept.isPending ||
autoTried.current
) {
return;
}
autoTried.current = true;
void acceptMutate(token)
.then((result) => {
router.replace(`${ROUTES.space}/${result.spaceSlug}`);
router.refresh();
})
.catch(() => {
// Surface via accept.error below.
});
}, [
signedIn,
preview.data,
accept.isSuccess,
accept.isPending,
acceptMutate,
token,
router,
]);

if (preview.isLoading || authPending) {
return <PageLoader fill="parent" label={t('loading')} />;
}
Expand All @@ -94,22 +66,18 @@ export function AcceptInvite({ token }: AcceptInviteProps) {
return null;
}

if (!signedIn) {
if (!isSignedIn) {
return (
<div className="flex flex-col gap-6">
<div>
<h1 className="font-display text-2xl font-semibold tracking-tight sm:text-3xl">
{t('title', { space: data.spaceName })}
</h1>
<p className="mt-2 text-sm text-muted-foreground">
{t('description', { role: data.roleName })}
</p>
{data.kind === 'email' && data.email ? (
<p className="mt-2 text-sm text-muted-foreground">
{t('emailHint', { email: data.email })}
</p>
) : null}
</div>
<InviteSummary
title={t('title', { space: data.spaceName })}
description={t('description', { role: roleLabel })}
emailHint={
data.kind === 'email' && data.email
? t('emailHint', { email: data.email })
: null
}
/>
<div className="flex flex-col gap-3 sm:flex-row">
<Link
href={signInHref}
Expand All @@ -130,7 +98,7 @@ export function AcceptInvite({ token }: AcceptInviteProps) {
);
}

if (accept.isPending || (!accept.isError && !accept.isSuccess)) {
if (accept.isSuccess) {
return <PageLoader fill="parent" label={t('joining')} />;
}

Expand Down Expand Up @@ -162,18 +130,74 @@ export function AcceptInvite({ token }: AcceptInviteProps) {
type="button"
variant="outline"
className="self-start"
onClick={() => {
autoTried.current = false;
accept.reset();
}}
onClick={() => accept.reset()}
>
{t('tryAgain')}
</Button>
</div>
);
}

return <PageLoader fill="parent" label={t('joining')} />;
return (
<div className="flex flex-col gap-6">
<InviteSummary
title={t('title', { space: data.spaceName })}
description={t('confirmDescription', { role: roleLabel })}
emailHint={
data.kind === 'email' && data.email
? t('emailHintSignedIn', { email: data.email })
: null
}
/>
<div className="flex flex-col gap-3 sm:flex-row">
<Button
type="button"
disabled={accept.isPending}
onClick={() => {
void accept
.mutateAsync(token)
.then((result) => {
router.replace(`${ROUTES.space}/${result.spaceSlug}`);
router.refresh();
})
.catch(() => {
/* surfaced via accept.error */
});
}}
>
{accept.isPending ? t('joining') : t('accept')}
</Button>
<Link
href={ROUTES.dashboard}
className={cn(buttonVariants({ variant: 'outline' }))}
>
{t('decline')}
</Link>
</div>
</div>
);
}

function InviteSummary({
title,
description,
emailHint,
}: {
title: string;
description: string;
emailHint: string | null;
}) {
return (
<div>
<h1 className="font-display text-2xl font-semibold tracking-tight sm:text-3xl">
{title}
</h1>
<p className="mt-2 text-sm text-muted-foreground">{description}</p>
{emailHint ? (
<p className="mt-2 text-sm text-muted-foreground">{emailHint}</p>
) : null}
</div>
);
}

function InviteMessage({ title, body }: { title: string; body: string }) {
Expand Down
Loading
Loading