feat: ECHODO app shell, Coolify deploy, Authentik + Umami
Bundles in-flight ECHODO work with the Coolify deployment configuration:
App
- New routes: ai, forms, planner, settings (templates/types), teams,
doc detail, whiteboard detail
- New components: app shell rework (icon-rail, top-header), forms
builder/renderer/responses, types manager, objects creation dialog,
card primitive, form + overview views
- New tRPC routers: favorites, forms, types, workspaces; updates to
health and objects routers
- Markdown backlog sync (packages/database) + cursor-sync schema/migrations
- Schema additions: forms, types, favorites, markdown_backlog, cursor_sync
- Initial Drizzle migrations checked in
Deployment
- docker/docker-compose.coolify.yml: drops bundled Postgres/Redis
(uses CT 102 shared services), removes host port mappings, adds
Coolify SERVICE_FQDN_* magic vars for web + collab
- .env.example rewritten as the full ECHODO/Coolify variable manifest
- NextAuth gains an Authentik OIDC provider (gated on env presence)
- Root layout injects Umami tracking script when configured;
metadata title flipped to ECHODO
Security
- .gitignore expanded to exclude AGENT-DEPLOY.md, .env.*, secrets/,
credentials.*, *.key, *.crt, *.pem, ssh keys
Made-with: Cursor
2026-04-26 15:34:34 -04:00
|
|
|
"use client";
|
|
|
|
|
|
feat(invites): invite dialog + accept route + teams UI (Task 2, part 2/2)
Closes Task-workspace-invites-and-roles end-to-end. Builds on the
schema + procedures from 7a55d6d (Task 2, part 1/2).
apps/web/components/teams/invite-dialog.tsx (new):
* Owner/admin-only sheet that wraps invites.create. Email input + role
select (member/admin; owner deliberately excluded — single-owner
model means ownership transfer is a separate flow, not a fresh
invite). On success surfaces the accept URL with a copy-to-clipboard
affordance and a "your email isn't wired up yet, paste this directly"
hint. Plain text input in this commit; the smart recipient
autocomplete combobox from Task 3 will swap it in via a follow-up
edit to this same file (subagent is working that in parallel).
apps/web/app/(app)/[workspaceSlug]/teams/page.tsx (rewrite):
* Replaced the placeholder "Invite coming soon" button with the new
InviteDialog. Adds:
- Pending invites section (admin/owner only) listing each open
invite with email, role, expiry-relative time, and Copy link /
Revoke actions.
- Per-member kebab menu with role-change actions and Remove. Only
owners can promote anyone to owner; admins can move people
between admin/member only. The "demote to member" item disables
on the last-owner row (the server enforces this anyway with a
clear error; UI just avoids surfacing a click that'd 400).
- "You're a member, not a manager" footer hint for non-owners/admins.
* Caller's role is derived from the members query (no extra
round-trip) — the membership row IS the source of truth for who
can manage what.
* Mutation errors surface inline at the page level with a Dismiss
action — kebab/copy actions that hit the last-owner guard, expired-
token error, etc. don't fail silently.
apps/web/app/invite/[token]/page.tsx (new):
* Public-by-token redeem page. Four phases handled cleanly:
1. No session yet -> "Sign in to continue" with callbackUrl set so
the user lands back here after auth.
2. Authenticated, accepting -> spinner.
3. Success -> redirect to the workspace's slug-rooted URL.
4. FORBIDDEN with cause.reason='email_not_owned' -> dedicated
explainer page showing both the invited email AND the user's
current sign-in email, with deep links to link the invited email
via OAuth and try again. (This is the Task 1 invariant
surfacing through the UI: we never silently accept an invite
under a mismatched identity.)
* All other accept errors (not found / revoked / expired) render the
message verbatim with a "Go home" button.
apps/web/server/trpc.ts:
* Added a small errorFormatter that exposes `error.cause` to the
client when it's a plain object. Required for the invite-accept
explainer page to read `cause.invitedEmail` off the TRPCError. The
cause-payload contract is "small, pure data, no secrets" — anything
the server throws as a cause is also visible client-side.
End-to-end behavior verified statically: type-check clean across all
6 packages. Smoke test path:
1. As admin@tasks.dev, open /<workspace>/teams.
2. Click Invite -> dialog opens -> enter an email, pick member, send.
3. See the success state with the accept URL. Copy it.
4. Open the URL in a different browser (or incognito). With no session
-> sign-in prompt. After auth -> invite accepts and you land in
the workspace. With a session whose email doesn't match -> the
email-mismatch explainer renders.
Note: the test runner shows three new tests in packages/shared
(invite-suggestions.test.ts) from the in-progress Task-3 subagent.
Those land with their own commit when the subagent finishes — they're
visible here only because they share the working tree.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 11:34:56 -04:00
|
|
|
import * as React from "react";
|
feat: ECHODO app shell, Coolify deploy, Authentik + Umami
Bundles in-flight ECHODO work with the Coolify deployment configuration:
App
- New routes: ai, forms, planner, settings (templates/types), teams,
doc detail, whiteboard detail
- New components: app shell rework (icon-rail, top-header), forms
builder/renderer/responses, types manager, objects creation dialog,
card primitive, form + overview views
- New tRPC routers: favorites, forms, types, workspaces; updates to
health and objects routers
- Markdown backlog sync (packages/database) + cursor-sync schema/migrations
- Schema additions: forms, types, favorites, markdown_backlog, cursor_sync
- Initial Drizzle migrations checked in
Deployment
- docker/docker-compose.coolify.yml: drops bundled Postgres/Redis
(uses CT 102 shared services), removes host port mappings, adds
Coolify SERVICE_FQDN_* magic vars for web + collab
- .env.example rewritten as the full ECHODO/Coolify variable manifest
- NextAuth gains an Authentik OIDC provider (gated on env presence)
- Root layout injects Umami tracking script when configured;
metadata title flipped to ECHODO
Security
- .gitignore expanded to exclude AGENT-DEPLOY.md, .env.*, secrets/,
credentials.*, *.key, *.crt, *.pem, ssh keys
Made-with: Cursor
2026-04-26 15:34:34 -04:00
|
|
|
import { useParams } from "next/navigation";
|
feat(invites): invite dialog + accept route + teams UI (Task 2, part 2/2)
Closes Task-workspace-invites-and-roles end-to-end. Builds on the
schema + procedures from 7a55d6d (Task 2, part 1/2).
apps/web/components/teams/invite-dialog.tsx (new):
* Owner/admin-only sheet that wraps invites.create. Email input + role
select (member/admin; owner deliberately excluded — single-owner
model means ownership transfer is a separate flow, not a fresh
invite). On success surfaces the accept URL with a copy-to-clipboard
affordance and a "your email isn't wired up yet, paste this directly"
hint. Plain text input in this commit; the smart recipient
autocomplete combobox from Task 3 will swap it in via a follow-up
edit to this same file (subagent is working that in parallel).
apps/web/app/(app)/[workspaceSlug]/teams/page.tsx (rewrite):
* Replaced the placeholder "Invite coming soon" button with the new
InviteDialog. Adds:
- Pending invites section (admin/owner only) listing each open
invite with email, role, expiry-relative time, and Copy link /
Revoke actions.
- Per-member kebab menu with role-change actions and Remove. Only
owners can promote anyone to owner; admins can move people
between admin/member only. The "demote to member" item disables
on the last-owner row (the server enforces this anyway with a
clear error; UI just avoids surfacing a click that'd 400).
- "You're a member, not a manager" footer hint for non-owners/admins.
* Caller's role is derived from the members query (no extra
round-trip) — the membership row IS the source of truth for who
can manage what.
* Mutation errors surface inline at the page level with a Dismiss
action — kebab/copy actions that hit the last-owner guard, expired-
token error, etc. don't fail silently.
apps/web/app/invite/[token]/page.tsx (new):
* Public-by-token redeem page. Four phases handled cleanly:
1. No session yet -> "Sign in to continue" with callbackUrl set so
the user lands back here after auth.
2. Authenticated, accepting -> spinner.
3. Success -> redirect to the workspace's slug-rooted URL.
4. FORBIDDEN with cause.reason='email_not_owned' -> dedicated
explainer page showing both the invited email AND the user's
current sign-in email, with deep links to link the invited email
via OAuth and try again. (This is the Task 1 invariant
surfacing through the UI: we never silently accept an invite
under a mismatched identity.)
* All other accept errors (not found / revoked / expired) render the
message verbatim with a "Go home" button.
apps/web/server/trpc.ts:
* Added a small errorFormatter that exposes `error.cause` to the
client when it's a plain object. Required for the invite-accept
explainer page to read `cause.invitedEmail` off the TRPCError. The
cause-payload contract is "small, pure data, no secrets" — anything
the server throws as a cause is also visible client-side.
End-to-end behavior verified statically: type-check clean across all
6 packages. Smoke test path:
1. As admin@tasks.dev, open /<workspace>/teams.
2. Click Invite -> dialog opens -> enter an email, pick member, send.
3. See the success state with the accept URL. Copy it.
4. Open the URL in a different browser (or incognito). With no session
-> sign-in prompt. After auth -> invite accepts and you land in
the workspace. With a session whose email doesn't match -> the
email-mismatch explainer renders.
Note: the test runner shows three new tests in packages/shared
(invite-suggestions.test.ts) from the in-progress Task-3 subagent.
Those land with their own commit when the subagent finishes — they're
visible here only because they share the working tree.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 11:34:56 -04:00
|
|
|
import { useSession } from "next-auth/react";
|
|
|
|
|
|
|
|
|
|
// Membership-derived role drives which surfaces render (Invite button,
|
|
|
|
|
// pending-invites section, kebab menus). The server enforces the same
|
|
|
|
|
// rules on every mutation regardless of what the UI shows.
|
|
|
|
|
import { Copy, Loader2, MailCheck, MoreHorizontal, Trash2, UserCog } from "lucide-react";
|
feat: ECHODO app shell, Coolify deploy, Authentik + Umami
Bundles in-flight ECHODO work with the Coolify deployment configuration:
App
- New routes: ai, forms, planner, settings (templates/types), teams,
doc detail, whiteboard detail
- New components: app shell rework (icon-rail, top-header), forms
builder/renderer/responses, types manager, objects creation dialog,
card primitive, form + overview views
- New tRPC routers: favorites, forms, types, workspaces; updates to
health and objects routers
- Markdown backlog sync (packages/database) + cursor-sync schema/migrations
- Schema additions: forms, types, favorites, markdown_backlog, cursor_sync
- Initial Drizzle migrations checked in
Deployment
- docker/docker-compose.coolify.yml: drops bundled Postgres/Redis
(uses CT 102 shared services), removes host port mappings, adds
Coolify SERVICE_FQDN_* magic vars for web + collab
- .env.example rewritten as the full ECHODO/Coolify variable manifest
- NextAuth gains an Authentik OIDC provider (gated on env presence)
- Root layout injects Umami tracking script when configured;
metadata title flipped to ECHODO
Security
- .gitignore expanded to exclude AGENT-DEPLOY.md, .env.*, secrets/,
credentials.*, *.key, *.crt, *.pem, ssh keys
Made-with: Cursor
2026-04-26 15:34:34 -04:00
|
|
|
|
|
|
|
|
import { cn } from "@/lib/utils";
|
|
|
|
|
import { api } from "@/lib/trpc";
|
|
|
|
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
|
|
|
|
import { Badge } from "@/components/ui/badge";
|
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
|
|
|
import { Card, CardContent } from "@/components/ui/card";
|
feat(invites): invite dialog + accept route + teams UI (Task 2, part 2/2)
Closes Task-workspace-invites-and-roles end-to-end. Builds on the
schema + procedures from 7a55d6d (Task 2, part 1/2).
apps/web/components/teams/invite-dialog.tsx (new):
* Owner/admin-only sheet that wraps invites.create. Email input + role
select (member/admin; owner deliberately excluded — single-owner
model means ownership transfer is a separate flow, not a fresh
invite). On success surfaces the accept URL with a copy-to-clipboard
affordance and a "your email isn't wired up yet, paste this directly"
hint. Plain text input in this commit; the smart recipient
autocomplete combobox from Task 3 will swap it in via a follow-up
edit to this same file (subagent is working that in parallel).
apps/web/app/(app)/[workspaceSlug]/teams/page.tsx (rewrite):
* Replaced the placeholder "Invite coming soon" button with the new
InviteDialog. Adds:
- Pending invites section (admin/owner only) listing each open
invite with email, role, expiry-relative time, and Copy link /
Revoke actions.
- Per-member kebab menu with role-change actions and Remove. Only
owners can promote anyone to owner; admins can move people
between admin/member only. The "demote to member" item disables
on the last-owner row (the server enforces this anyway with a
clear error; UI just avoids surfacing a click that'd 400).
- "You're a member, not a manager" footer hint for non-owners/admins.
* Caller's role is derived from the members query (no extra
round-trip) — the membership row IS the source of truth for who
can manage what.
* Mutation errors surface inline at the page level with a Dismiss
action — kebab/copy actions that hit the last-owner guard, expired-
token error, etc. don't fail silently.
apps/web/app/invite/[token]/page.tsx (new):
* Public-by-token redeem page. Four phases handled cleanly:
1. No session yet -> "Sign in to continue" with callbackUrl set so
the user lands back here after auth.
2. Authenticated, accepting -> spinner.
3. Success -> redirect to the workspace's slug-rooted URL.
4. FORBIDDEN with cause.reason='email_not_owned' -> dedicated
explainer page showing both the invited email AND the user's
current sign-in email, with deep links to link the invited email
via OAuth and try again. (This is the Task 1 invariant
surfacing through the UI: we never silently accept an invite
under a mismatched identity.)
* All other accept errors (not found / revoked / expired) render the
message verbatim with a "Go home" button.
apps/web/server/trpc.ts:
* Added a small errorFormatter that exposes `error.cause` to the
client when it's a plain object. Required for the invite-accept
explainer page to read `cause.invitedEmail` off the TRPCError. The
cause-payload contract is "small, pure data, no secrets" — anything
the server throws as a cause is also visible client-side.
End-to-end behavior verified statically: type-check clean across all
6 packages. Smoke test path:
1. As admin@tasks.dev, open /<workspace>/teams.
2. Click Invite -> dialog opens -> enter an email, pick member, send.
3. See the success state with the accept URL. Copy it.
4. Open the URL in a different browser (or incognito). With no session
-> sign-in prompt. After auth -> invite accepts and you land in
the workspace. With a session whose email doesn't match -> the
email-mismatch explainer renders.
Note: the test runner shows three new tests in packages/shared
(invite-suggestions.test.ts) from the in-progress Task-3 subagent.
Those land with their own commit when the subagent finishes — they're
visible here only because they share the working tree.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 11:34:56 -04:00
|
|
|
import {
|
|
|
|
|
DropdownMenu,
|
|
|
|
|
DropdownMenuContent,
|
|
|
|
|
DropdownMenuItem,
|
|
|
|
|
DropdownMenuLabel,
|
|
|
|
|
DropdownMenuSeparator,
|
|
|
|
|
DropdownMenuTrigger,
|
|
|
|
|
} from "@/components/ui/dropdown-menu";
|
|
|
|
|
import { InviteDialog } from "@/components/teams/invite-dialog";
|
|
|
|
|
|
|
|
|
|
type WorkspaceRole = "owner" | "admin" | "member";
|
feat: ECHODO app shell, Coolify deploy, Authentik + Umami
Bundles in-flight ECHODO work with the Coolify deployment configuration:
App
- New routes: ai, forms, planner, settings (templates/types), teams,
doc detail, whiteboard detail
- New components: app shell rework (icon-rail, top-header), forms
builder/renderer/responses, types manager, objects creation dialog,
card primitive, form + overview views
- New tRPC routers: favorites, forms, types, workspaces; updates to
health and objects routers
- Markdown backlog sync (packages/database) + cursor-sync schema/migrations
- Schema additions: forms, types, favorites, markdown_backlog, cursor_sync
- Initial Drizzle migrations checked in
Deployment
- docker/docker-compose.coolify.yml: drops bundled Postgres/Redis
(uses CT 102 shared services), removes host port mappings, adds
Coolify SERVICE_FQDN_* magic vars for web + collab
- .env.example rewritten as the full ECHODO/Coolify variable manifest
- NextAuth gains an Authentik OIDC provider (gated on env presence)
- Root layout injects Umami tracking script when configured;
metadata title flipped to ECHODO
Security
- .gitignore expanded to exclude AGENT-DEPLOY.md, .env.*, secrets/,
credentials.*, *.key, *.crt, *.pem, ssh keys
Made-with: Cursor
2026-04-26 15:34:34 -04:00
|
|
|
|
|
|
|
|
function memberInitials(name: string | null | undefined, email: string) {
|
|
|
|
|
const n = name?.trim();
|
|
|
|
|
if (n) return n.slice(0, 1).toUpperCase();
|
|
|
|
|
return email.trim().slice(0, 1).toUpperCase();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function formatRoleLabel(role: string) {
|
|
|
|
|
const key = role.toLowerCase();
|
|
|
|
|
const labels: Record<string, string> = {
|
|
|
|
|
owner: "Owner",
|
|
|
|
|
admin: "Admin",
|
|
|
|
|
member: "Member",
|
|
|
|
|
};
|
|
|
|
|
return labels[key] ?? role.charAt(0).toUpperCase() + role.slice(1).toLowerCase();
|
|
|
|
|
}
|
|
|
|
|
|
feat(invites): invite dialog + accept route + teams UI (Task 2, part 2/2)
Closes Task-workspace-invites-and-roles end-to-end. Builds on the
schema + procedures from 7a55d6d (Task 2, part 1/2).
apps/web/components/teams/invite-dialog.tsx (new):
* Owner/admin-only sheet that wraps invites.create. Email input + role
select (member/admin; owner deliberately excluded — single-owner
model means ownership transfer is a separate flow, not a fresh
invite). On success surfaces the accept URL with a copy-to-clipboard
affordance and a "your email isn't wired up yet, paste this directly"
hint. Plain text input in this commit; the smart recipient
autocomplete combobox from Task 3 will swap it in via a follow-up
edit to this same file (subagent is working that in parallel).
apps/web/app/(app)/[workspaceSlug]/teams/page.tsx (rewrite):
* Replaced the placeholder "Invite coming soon" button with the new
InviteDialog. Adds:
- Pending invites section (admin/owner only) listing each open
invite with email, role, expiry-relative time, and Copy link /
Revoke actions.
- Per-member kebab menu with role-change actions and Remove. Only
owners can promote anyone to owner; admins can move people
between admin/member only. The "demote to member" item disables
on the last-owner row (the server enforces this anyway with a
clear error; UI just avoids surfacing a click that'd 400).
- "You're a member, not a manager" footer hint for non-owners/admins.
* Caller's role is derived from the members query (no extra
round-trip) — the membership row IS the source of truth for who
can manage what.
* Mutation errors surface inline at the page level with a Dismiss
action — kebab/copy actions that hit the last-owner guard, expired-
token error, etc. don't fail silently.
apps/web/app/invite/[token]/page.tsx (new):
* Public-by-token redeem page. Four phases handled cleanly:
1. No session yet -> "Sign in to continue" with callbackUrl set so
the user lands back here after auth.
2. Authenticated, accepting -> spinner.
3. Success -> redirect to the workspace's slug-rooted URL.
4. FORBIDDEN with cause.reason='email_not_owned' -> dedicated
explainer page showing both the invited email AND the user's
current sign-in email, with deep links to link the invited email
via OAuth and try again. (This is the Task 1 invariant
surfacing through the UI: we never silently accept an invite
under a mismatched identity.)
* All other accept errors (not found / revoked / expired) render the
message verbatim with a "Go home" button.
apps/web/server/trpc.ts:
* Added a small errorFormatter that exposes `error.cause` to the
client when it's a plain object. Required for the invite-accept
explainer page to read `cause.invitedEmail` off the TRPCError. The
cause-payload contract is "small, pure data, no secrets" — anything
the server throws as a cause is also visible client-side.
End-to-end behavior verified statically: type-check clean across all
6 packages. Smoke test path:
1. As admin@tasks.dev, open /<workspace>/teams.
2. Click Invite -> dialog opens -> enter an email, pick member, send.
3. See the success state with the accept URL. Copy it.
4. Open the URL in a different browser (or incognito). With no session
-> sign-in prompt. After auth -> invite accepts and you land in
the workspace. With a session whose email doesn't match -> the
email-mismatch explainer renders.
Note: the test runner shows three new tests in packages/shared
(invite-suggestions.test.ts) from the in-progress Task-3 subagent.
Those land with their own commit when the subagent finishes — they're
visible here only because they share the working tree.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 11:34:56 -04:00
|
|
|
function relativeFromNow(when: Date | string | null | undefined): string {
|
|
|
|
|
if (!when) return "—";
|
|
|
|
|
const target = when instanceof Date ? when.getTime() : new Date(when).getTime();
|
|
|
|
|
const diff = target - Date.now();
|
|
|
|
|
if (Number.isNaN(diff)) return "—";
|
|
|
|
|
const sign = diff < 0 ? "ago" : "from now";
|
|
|
|
|
const abs = Math.abs(diff);
|
|
|
|
|
const min = Math.floor(abs / 60_000);
|
|
|
|
|
if (min < 60) return `${min}m ${sign}`;
|
|
|
|
|
const hr = Math.floor(min / 60);
|
|
|
|
|
if (hr < 24) return `${hr}h ${sign}`;
|
|
|
|
|
const day = Math.floor(hr / 24);
|
|
|
|
|
return `${day}d ${sign}`;
|
|
|
|
|
}
|
|
|
|
|
|
feat: ECHODO app shell, Coolify deploy, Authentik + Umami
Bundles in-flight ECHODO work with the Coolify deployment configuration:
App
- New routes: ai, forms, planner, settings (templates/types), teams,
doc detail, whiteboard detail
- New components: app shell rework (icon-rail, top-header), forms
builder/renderer/responses, types manager, objects creation dialog,
card primitive, form + overview views
- New tRPC routers: favorites, forms, types, workspaces; updates to
health and objects routers
- Markdown backlog sync (packages/database) + cursor-sync schema/migrations
- Schema additions: forms, types, favorites, markdown_backlog, cursor_sync
- Initial Drizzle migrations checked in
Deployment
- docker/docker-compose.coolify.yml: drops bundled Postgres/Redis
(uses CT 102 shared services), removes host port mappings, adds
Coolify SERVICE_FQDN_* magic vars for web + collab
- .env.example rewritten as the full ECHODO/Coolify variable manifest
- NextAuth gains an Authentik OIDC provider (gated on env presence)
- Root layout injects Umami tracking script when configured;
metadata title flipped to ECHODO
Security
- .gitignore expanded to exclude AGENT-DEPLOY.md, .env.*, secrets/,
credentials.*, *.key, *.crt, *.pem, ssh keys
Made-with: Cursor
2026-04-26 15:34:34 -04:00
|
|
|
function MemberCardSkeleton({ className }: { className?: string }) {
|
|
|
|
|
return (
|
|
|
|
|
<Card className={cn("overflow-hidden", className)}>
|
|
|
|
|
<CardContent className="flex items-center gap-4 p-6">
|
|
|
|
|
<div className="size-10 shrink-0 animate-pulse rounded-full bg-muted" />
|
|
|
|
|
<div className="min-w-0 flex-1 space-y-2">
|
|
|
|
|
<div className="h-4 w-32 animate-pulse rounded-md bg-muted" />
|
|
|
|
|
<div className="h-3 w-48 max-w-full animate-pulse rounded-md bg-muted" />
|
|
|
|
|
</div>
|
|
|
|
|
<div className="h-5 w-16 shrink-0 animate-pulse rounded-full bg-muted" />
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default function TeamsPage() {
|
|
|
|
|
const params = useParams();
|
feat(invites): invite dialog + accept route + teams UI (Task 2, part 2/2)
Closes Task-workspace-invites-and-roles end-to-end. Builds on the
schema + procedures from 7a55d6d (Task 2, part 1/2).
apps/web/components/teams/invite-dialog.tsx (new):
* Owner/admin-only sheet that wraps invites.create. Email input + role
select (member/admin; owner deliberately excluded — single-owner
model means ownership transfer is a separate flow, not a fresh
invite). On success surfaces the accept URL with a copy-to-clipboard
affordance and a "your email isn't wired up yet, paste this directly"
hint. Plain text input in this commit; the smart recipient
autocomplete combobox from Task 3 will swap it in via a follow-up
edit to this same file (subagent is working that in parallel).
apps/web/app/(app)/[workspaceSlug]/teams/page.tsx (rewrite):
* Replaced the placeholder "Invite coming soon" button with the new
InviteDialog. Adds:
- Pending invites section (admin/owner only) listing each open
invite with email, role, expiry-relative time, and Copy link /
Revoke actions.
- Per-member kebab menu with role-change actions and Remove. Only
owners can promote anyone to owner; admins can move people
between admin/member only. The "demote to member" item disables
on the last-owner row (the server enforces this anyway with a
clear error; UI just avoids surfacing a click that'd 400).
- "You're a member, not a manager" footer hint for non-owners/admins.
* Caller's role is derived from the members query (no extra
round-trip) — the membership row IS the source of truth for who
can manage what.
* Mutation errors surface inline at the page level with a Dismiss
action — kebab/copy actions that hit the last-owner guard, expired-
token error, etc. don't fail silently.
apps/web/app/invite/[token]/page.tsx (new):
* Public-by-token redeem page. Four phases handled cleanly:
1. No session yet -> "Sign in to continue" with callbackUrl set so
the user lands back here after auth.
2. Authenticated, accepting -> spinner.
3. Success -> redirect to the workspace's slug-rooted URL.
4. FORBIDDEN with cause.reason='email_not_owned' -> dedicated
explainer page showing both the invited email AND the user's
current sign-in email, with deep links to link the invited email
via OAuth and try again. (This is the Task 1 invariant
surfacing through the UI: we never silently accept an invite
under a mismatched identity.)
* All other accept errors (not found / revoked / expired) render the
message verbatim with a "Go home" button.
apps/web/server/trpc.ts:
* Added a small errorFormatter that exposes `error.cause` to the
client when it's a plain object. Required for the invite-accept
explainer page to read `cause.invitedEmail` off the TRPCError. The
cause-payload contract is "small, pure data, no secrets" — anything
the server throws as a cause is also visible client-side.
End-to-end behavior verified statically: type-check clean across all
6 packages. Smoke test path:
1. As admin@tasks.dev, open /<workspace>/teams.
2. Click Invite -> dialog opens -> enter an email, pick member, send.
3. See the success state with the accept URL. Copy it.
4. Open the URL in a different browser (or incognito). With no session
-> sign-in prompt. After auth -> invite accepts and you land in
the workspace. With a session whose email doesn't match -> the
email-mismatch explainer renders.
Note: the test runner shows three new tests in packages/shared
(invite-suggestions.test.ts) from the in-progress Task-3 subagent.
Those land with their own commit when the subagent finishes — they're
visible here only because they share the working tree.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 11:34:56 -04:00
|
|
|
const utils = api.useUtils();
|
multi-tenancy: promote workspaces to top-level table
Block A of the EchoDo plan. Workspaces used to live as `objects(type='workspace')`,
which made it impossible to put a real RLS-friendly tenant boundary on the schema
or to give each workspace a stable URL slug. This commit:
- Adds a top-level `workspaces` table (slug unique, owner FK, plan_tier hook).
- Migrates the 8 anchor tables (objects, workspace_members, object_type_defs,
property_definitions, templates, forms, markdown_backlog_items,
cursor_sync_mappings) to FK into `workspaces.id` instead of `objects.id`,
with a hand-augmented data-copy migration that preserves IDs and slug-collision-
proofs on backfill.
- Introduces a `workspaceProcedure` tRPC middleware + `resolveWorkspace` helper
that take a UUID-or-slug `workspace` handle and expose `ctx.workspace`. All
tenant-scoped routers (objects, types, properties, templates, forms, search,
ai, relations, favorites) now flow through it.
- Updates the web app to pass `workspace` slugs from the URL (or store) instead
of the old `workspaceId`, including a workspace-sync layer that rewrites
/<UUID>/... links to /<slug>/...
- Updates the MCP tools (list_objects, create_object, search_objects) and the
workspace://{handle}/tree resource to accept either a slug or UUID so existing
agents keep working.
- Adds a Create Workspace dialog and a Workspace Settings page (rename + slug
rename with redirect, owner-only archive).
Verified locally against a fresh Postgres: migration applies cleanly, slug
uniqueness holds, tenant data is isolated by workspace_id, slug↔UUID resolution
works in both directions, and ON DELETE CASCADE cleans up child rows in the
correct workspace only.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-07 00:02:55 -04:00
|
|
|
const rawSlug = params?.workspaceSlug;
|
|
|
|
|
const workspaceSlug = typeof rawSlug === "string" ? rawSlug : undefined;
|
feat: ECHODO app shell, Coolify deploy, Authentik + Umami
Bundles in-flight ECHODO work with the Coolify deployment configuration:
App
- New routes: ai, forms, planner, settings (templates/types), teams,
doc detail, whiteboard detail
- New components: app shell rework (icon-rail, top-header), forms
builder/renderer/responses, types manager, objects creation dialog,
card primitive, form + overview views
- New tRPC routers: favorites, forms, types, workspaces; updates to
health and objects routers
- Markdown backlog sync (packages/database) + cursor-sync schema/migrations
- Schema additions: forms, types, favorites, markdown_backlog, cursor_sync
- Initial Drizzle migrations checked in
Deployment
- docker/docker-compose.coolify.yml: drops bundled Postgres/Redis
(uses CT 102 shared services), removes host port mappings, adds
Coolify SERVICE_FQDN_* magic vars for web + collab
- .env.example rewritten as the full ECHODO/Coolify variable manifest
- NextAuth gains an Authentik OIDC provider (gated on env presence)
- Root layout injects Umami tracking script when configured;
metadata title flipped to ECHODO
Security
- .gitignore expanded to exclude AGENT-DEPLOY.md, .env.*, secrets/,
credentials.*, *.key, *.crt, *.pem, ssh keys
Made-with: Cursor
2026-04-26 15:34:34 -04:00
|
|
|
|
feat(invites): invite dialog + accept route + teams UI (Task 2, part 2/2)
Closes Task-workspace-invites-and-roles end-to-end. Builds on the
schema + procedures from 7a55d6d (Task 2, part 1/2).
apps/web/components/teams/invite-dialog.tsx (new):
* Owner/admin-only sheet that wraps invites.create. Email input + role
select (member/admin; owner deliberately excluded — single-owner
model means ownership transfer is a separate flow, not a fresh
invite). On success surfaces the accept URL with a copy-to-clipboard
affordance and a "your email isn't wired up yet, paste this directly"
hint. Plain text input in this commit; the smart recipient
autocomplete combobox from Task 3 will swap it in via a follow-up
edit to this same file (subagent is working that in parallel).
apps/web/app/(app)/[workspaceSlug]/teams/page.tsx (rewrite):
* Replaced the placeholder "Invite coming soon" button with the new
InviteDialog. Adds:
- Pending invites section (admin/owner only) listing each open
invite with email, role, expiry-relative time, and Copy link /
Revoke actions.
- Per-member kebab menu with role-change actions and Remove. Only
owners can promote anyone to owner; admins can move people
between admin/member only. The "demote to member" item disables
on the last-owner row (the server enforces this anyway with a
clear error; UI just avoids surfacing a click that'd 400).
- "You're a member, not a manager" footer hint for non-owners/admins.
* Caller's role is derived from the members query (no extra
round-trip) — the membership row IS the source of truth for who
can manage what.
* Mutation errors surface inline at the page level with a Dismiss
action — kebab/copy actions that hit the last-owner guard, expired-
token error, etc. don't fail silently.
apps/web/app/invite/[token]/page.tsx (new):
* Public-by-token redeem page. Four phases handled cleanly:
1. No session yet -> "Sign in to continue" with callbackUrl set so
the user lands back here after auth.
2. Authenticated, accepting -> spinner.
3. Success -> redirect to the workspace's slug-rooted URL.
4. FORBIDDEN with cause.reason='email_not_owned' -> dedicated
explainer page showing both the invited email AND the user's
current sign-in email, with deep links to link the invited email
via OAuth and try again. (This is the Task 1 invariant
surfacing through the UI: we never silently accept an invite
under a mismatched identity.)
* All other accept errors (not found / revoked / expired) render the
message verbatim with a "Go home" button.
apps/web/server/trpc.ts:
* Added a small errorFormatter that exposes `error.cause` to the
client when it's a plain object. Required for the invite-accept
explainer page to read `cause.invitedEmail` off the TRPCError. The
cause-payload contract is "small, pure data, no secrets" — anything
the server throws as a cause is also visible client-side.
End-to-end behavior verified statically: type-check clean across all
6 packages. Smoke test path:
1. As admin@tasks.dev, open /<workspace>/teams.
2. Click Invite -> dialog opens -> enter an email, pick member, send.
3. See the success state with the accept URL. Copy it.
4. Open the URL in a different browser (or incognito). With no session
-> sign-in prompt. After auth -> invite accepts and you land in
the workspace. With a session whose email doesn't match -> the
email-mismatch explainer renders.
Note: the test runner shows three new tests in packages/shared
(invite-suggestions.test.ts) from the in-progress Task-3 subagent.
Those land with their own commit when the subagent finishes — they're
visible here only because they share the working tree.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 11:34:56 -04:00
|
|
|
const enabled = Boolean(workspaceSlug);
|
|
|
|
|
const membersQuery = api.workspaces.listMembers.useQuery(
|
multi-tenancy: promote workspaces to top-level table
Block A of the EchoDo plan. Workspaces used to live as `objects(type='workspace')`,
which made it impossible to put a real RLS-friendly tenant boundary on the schema
or to give each workspace a stable URL slug. This commit:
- Adds a top-level `workspaces` table (slug unique, owner FK, plan_tier hook).
- Migrates the 8 anchor tables (objects, workspace_members, object_type_defs,
property_definitions, templates, forms, markdown_backlog_items,
cursor_sync_mappings) to FK into `workspaces.id` instead of `objects.id`,
with a hand-augmented data-copy migration that preserves IDs and slug-collision-
proofs on backfill.
- Introduces a `workspaceProcedure` tRPC middleware + `resolveWorkspace` helper
that take a UUID-or-slug `workspace` handle and expose `ctx.workspace`. All
tenant-scoped routers (objects, types, properties, templates, forms, search,
ai, relations, favorites) now flow through it.
- Updates the web app to pass `workspace` slugs from the URL (or store) instead
of the old `workspaceId`, including a workspace-sync layer that rewrites
/<UUID>/... links to /<slug>/...
- Updates the MCP tools (list_objects, create_object, search_objects) and the
workspace://{handle}/tree resource to accept either a slug or UUID so existing
agents keep working.
- Adds a Create Workspace dialog and a Workspace Settings page (rename + slug
rename with redirect, owner-only archive).
Verified locally against a fresh Postgres: migration applies cleanly, slug
uniqueness holds, tenant data is isolated by workspace_id, slug↔UUID resolution
works in both directions, and ON DELETE CASCADE cleans up child rows in the
correct workspace only.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-07 00:02:55 -04:00
|
|
|
{ workspace: workspaceSlug as string },
|
feat(invites): invite dialog + accept route + teams UI (Task 2, part 2/2)
Closes Task-workspace-invites-and-roles end-to-end. Builds on the
schema + procedures from 7a55d6d (Task 2, part 1/2).
apps/web/components/teams/invite-dialog.tsx (new):
* Owner/admin-only sheet that wraps invites.create. Email input + role
select (member/admin; owner deliberately excluded — single-owner
model means ownership transfer is a separate flow, not a fresh
invite). On success surfaces the accept URL with a copy-to-clipboard
affordance and a "your email isn't wired up yet, paste this directly"
hint. Plain text input in this commit; the smart recipient
autocomplete combobox from Task 3 will swap it in via a follow-up
edit to this same file (subagent is working that in parallel).
apps/web/app/(app)/[workspaceSlug]/teams/page.tsx (rewrite):
* Replaced the placeholder "Invite coming soon" button with the new
InviteDialog. Adds:
- Pending invites section (admin/owner only) listing each open
invite with email, role, expiry-relative time, and Copy link /
Revoke actions.
- Per-member kebab menu with role-change actions and Remove. Only
owners can promote anyone to owner; admins can move people
between admin/member only. The "demote to member" item disables
on the last-owner row (the server enforces this anyway with a
clear error; UI just avoids surfacing a click that'd 400).
- "You're a member, not a manager" footer hint for non-owners/admins.
* Caller's role is derived from the members query (no extra
round-trip) — the membership row IS the source of truth for who
can manage what.
* Mutation errors surface inline at the page level with a Dismiss
action — kebab/copy actions that hit the last-owner guard, expired-
token error, etc. don't fail silently.
apps/web/app/invite/[token]/page.tsx (new):
* Public-by-token redeem page. Four phases handled cleanly:
1. No session yet -> "Sign in to continue" with callbackUrl set so
the user lands back here after auth.
2. Authenticated, accepting -> spinner.
3. Success -> redirect to the workspace's slug-rooted URL.
4. FORBIDDEN with cause.reason='email_not_owned' -> dedicated
explainer page showing both the invited email AND the user's
current sign-in email, with deep links to link the invited email
via OAuth and try again. (This is the Task 1 invariant
surfacing through the UI: we never silently accept an invite
under a mismatched identity.)
* All other accept errors (not found / revoked / expired) render the
message verbatim with a "Go home" button.
apps/web/server/trpc.ts:
* Added a small errorFormatter that exposes `error.cause` to the
client when it's a plain object. Required for the invite-accept
explainer page to read `cause.invitedEmail` off the TRPCError. The
cause-payload contract is "small, pure data, no secrets" — anything
the server throws as a cause is also visible client-side.
End-to-end behavior verified statically: type-check clean across all
6 packages. Smoke test path:
1. As admin@tasks.dev, open /<workspace>/teams.
2. Click Invite -> dialog opens -> enter an email, pick member, send.
3. See the success state with the accept URL. Copy it.
4. Open the URL in a different browser (or incognito). With no session
-> sign-in prompt. After auth -> invite accepts and you land in
the workspace. With a session whose email doesn't match -> the
email-mismatch explainer renders.
Note: the test runner shows three new tests in packages/shared
(invite-suggestions.test.ts) from the in-progress Task-3 subagent.
Those land with their own commit when the subagent finishes — they're
visible here only because they share the working tree.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 11:34:56 -04:00
|
|
|
{ enabled },
|
feat: ECHODO app shell, Coolify deploy, Authentik + Umami
Bundles in-flight ECHODO work with the Coolify deployment configuration:
App
- New routes: ai, forms, planner, settings (templates/types), teams,
doc detail, whiteboard detail
- New components: app shell rework (icon-rail, top-header), forms
builder/renderer/responses, types manager, objects creation dialog,
card primitive, form + overview views
- New tRPC routers: favorites, forms, types, workspaces; updates to
health and objects routers
- Markdown backlog sync (packages/database) + cursor-sync schema/migrations
- Schema additions: forms, types, favorites, markdown_backlog, cursor_sync
- Initial Drizzle migrations checked in
Deployment
- docker/docker-compose.coolify.yml: drops bundled Postgres/Redis
(uses CT 102 shared services), removes host port mappings, adds
Coolify SERVICE_FQDN_* magic vars for web + collab
- .env.example rewritten as the full ECHODO/Coolify variable manifest
- NextAuth gains an Authentik OIDC provider (gated on env presence)
- Root layout injects Umami tracking script when configured;
metadata title flipped to ECHODO
Security
- .gitignore expanded to exclude AGENT-DEPLOY.md, .env.*, secrets/,
credentials.*, *.key, *.crt, *.pem, ssh keys
Made-with: Cursor
2026-04-26 15:34:34 -04:00
|
|
|
);
|
|
|
|
|
|
feat(invites): invite dialog + accept route + teams UI (Task 2, part 2/2)
Closes Task-workspace-invites-and-roles end-to-end. Builds on the
schema + procedures from 7a55d6d (Task 2, part 1/2).
apps/web/components/teams/invite-dialog.tsx (new):
* Owner/admin-only sheet that wraps invites.create. Email input + role
select (member/admin; owner deliberately excluded — single-owner
model means ownership transfer is a separate flow, not a fresh
invite). On success surfaces the accept URL with a copy-to-clipboard
affordance and a "your email isn't wired up yet, paste this directly"
hint. Plain text input in this commit; the smart recipient
autocomplete combobox from Task 3 will swap it in via a follow-up
edit to this same file (subagent is working that in parallel).
apps/web/app/(app)/[workspaceSlug]/teams/page.tsx (rewrite):
* Replaced the placeholder "Invite coming soon" button with the new
InviteDialog. Adds:
- Pending invites section (admin/owner only) listing each open
invite with email, role, expiry-relative time, and Copy link /
Revoke actions.
- Per-member kebab menu with role-change actions and Remove. Only
owners can promote anyone to owner; admins can move people
between admin/member only. The "demote to member" item disables
on the last-owner row (the server enforces this anyway with a
clear error; UI just avoids surfacing a click that'd 400).
- "You're a member, not a manager" footer hint for non-owners/admins.
* Caller's role is derived from the members query (no extra
round-trip) — the membership row IS the source of truth for who
can manage what.
* Mutation errors surface inline at the page level with a Dismiss
action — kebab/copy actions that hit the last-owner guard, expired-
token error, etc. don't fail silently.
apps/web/app/invite/[token]/page.tsx (new):
* Public-by-token redeem page. Four phases handled cleanly:
1. No session yet -> "Sign in to continue" with callbackUrl set so
the user lands back here after auth.
2. Authenticated, accepting -> spinner.
3. Success -> redirect to the workspace's slug-rooted URL.
4. FORBIDDEN with cause.reason='email_not_owned' -> dedicated
explainer page showing both the invited email AND the user's
current sign-in email, with deep links to link the invited email
via OAuth and try again. (This is the Task 1 invariant
surfacing through the UI: we never silently accept an invite
under a mismatched identity.)
* All other accept errors (not found / revoked / expired) render the
message verbatim with a "Go home" button.
apps/web/server/trpc.ts:
* Added a small errorFormatter that exposes `error.cause` to the
client when it's a plain object. Required for the invite-accept
explainer page to read `cause.invitedEmail` off the TRPCError. The
cause-payload contract is "small, pure data, no secrets" — anything
the server throws as a cause is also visible client-side.
End-to-end behavior verified statically: type-check clean across all
6 packages. Smoke test path:
1. As admin@tasks.dev, open /<workspace>/teams.
2. Click Invite -> dialog opens -> enter an email, pick member, send.
3. See the success state with the accept URL. Copy it.
4. Open the URL in a different browser (or incognito). With no session
-> sign-in prompt. After auth -> invite accepts and you land in
the workspace. With a session whose email doesn't match -> the
email-mismatch explainer renders.
Note: the test runner shows three new tests in packages/shared
(invite-suggestions.test.ts) from the in-progress Task-3 subagent.
Those land with their own commit when the subagent finishes — they're
visible here only because they share the working tree.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 11:34:56 -04:00
|
|
|
// Caller's role lives on the members query — that's the source of truth for
|
|
|
|
|
// who's in this workspace and at what level. resolve() returns the workspace
|
|
|
|
|
// itself but not the membership-side role, so we derive it locally instead
|
|
|
|
|
// of round-tripping a second procedure.
|
|
|
|
|
const currentUserId = useSession().data?.user?.id;
|
|
|
|
|
const callerRole = membersQuery.data?.find((m) => m.id === currentUserId)?.role as
|
|
|
|
|
| WorkspaceRole
|
|
|
|
|
| undefined;
|
|
|
|
|
const canManage = callerRole === "owner" || callerRole === "admin";
|
|
|
|
|
|
|
|
|
|
const invitesQuery = api.invites.list.useQuery(
|
|
|
|
|
{ workspace: workspaceSlug as string },
|
|
|
|
|
{ enabled: enabled && canManage },
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const revokeMut = api.invites.revoke.useMutation({
|
|
|
|
|
onSuccess: () => utils.invites.list.invalidate({ workspace: workspaceSlug }),
|
|
|
|
|
});
|
|
|
|
|
const updateRoleMut = api.workspaces.updateMemberRole.useMutation({
|
|
|
|
|
onSuccess: () => utils.workspaces.listMembers.invalidate({ workspace: workspaceSlug }),
|
|
|
|
|
});
|
|
|
|
|
const removeMemberMut = api.workspaces.removeMember.useMutation({
|
|
|
|
|
onSuccess: () => utils.workspaces.listMembers.invalidate({ workspace: workspaceSlug }),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const [copyFlash, setCopyFlash] = React.useState<string | null>(null);
|
|
|
|
|
const copyInviteLink = async (token: string) => {
|
|
|
|
|
const url = `${window.location.origin}/invite/${token}`;
|
|
|
|
|
try {
|
|
|
|
|
await navigator.clipboard.writeText(url);
|
|
|
|
|
setCopyFlash(token);
|
|
|
|
|
window.setTimeout(() => setCopyFlash(null), 1500);
|
|
|
|
|
} catch {
|
|
|
|
|
window.prompt("Copy this invite link:", url);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (!workspaceSlug) {
|
|
|
|
|
return <p className="p-10 text-sm text-muted-foreground">Missing workspace.</p>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const members = membersQuery.data ?? [];
|
|
|
|
|
const ownerCount = members.filter((m) => m.role === "owner").length;
|
|
|
|
|
|
feat: ECHODO app shell, Coolify deploy, Authentik + Umami
Bundles in-flight ECHODO work with the Coolify deployment configuration:
App
- New routes: ai, forms, planner, settings (templates/types), teams,
doc detail, whiteboard detail
- New components: app shell rework (icon-rail, top-header), forms
builder/renderer/responses, types manager, objects creation dialog,
card primitive, form + overview views
- New tRPC routers: favorites, forms, types, workspaces; updates to
health and objects routers
- Markdown backlog sync (packages/database) + cursor-sync schema/migrations
- Schema additions: forms, types, favorites, markdown_backlog, cursor_sync
- Initial Drizzle migrations checked in
Deployment
- docker/docker-compose.coolify.yml: drops bundled Postgres/Redis
(uses CT 102 shared services), removes host port mappings, adds
Coolify SERVICE_FQDN_* magic vars for web + collab
- .env.example rewritten as the full ECHODO/Coolify variable manifest
- NextAuth gains an Authentik OIDC provider (gated on env presence)
- Root layout injects Umami tracking script when configured;
metadata title flipped to ECHODO
Security
- .gitignore expanded to exclude AGENT-DEPLOY.md, .env.*, secrets/,
credentials.*, *.key, *.crt, *.pem, ssh keys
Made-with: Cursor
2026-04-26 15:34:34 -04:00
|
|
|
return (
|
|
|
|
|
<div className="mx-auto max-w-5xl px-8 py-10">
|
|
|
|
|
<div className="mb-8 flex flex-wrap items-center justify-between gap-4">
|
|
|
|
|
<h1 className="text-3xl font-bold tracking-tight">Teams</h1>
|
feat(invites): invite dialog + accept route + teams UI (Task 2, part 2/2)
Closes Task-workspace-invites-and-roles end-to-end. Builds on the
schema + procedures from 7a55d6d (Task 2, part 1/2).
apps/web/components/teams/invite-dialog.tsx (new):
* Owner/admin-only sheet that wraps invites.create. Email input + role
select (member/admin; owner deliberately excluded — single-owner
model means ownership transfer is a separate flow, not a fresh
invite). On success surfaces the accept URL with a copy-to-clipboard
affordance and a "your email isn't wired up yet, paste this directly"
hint. Plain text input in this commit; the smart recipient
autocomplete combobox from Task 3 will swap it in via a follow-up
edit to this same file (subagent is working that in parallel).
apps/web/app/(app)/[workspaceSlug]/teams/page.tsx (rewrite):
* Replaced the placeholder "Invite coming soon" button with the new
InviteDialog. Adds:
- Pending invites section (admin/owner only) listing each open
invite with email, role, expiry-relative time, and Copy link /
Revoke actions.
- Per-member kebab menu with role-change actions and Remove. Only
owners can promote anyone to owner; admins can move people
between admin/member only. The "demote to member" item disables
on the last-owner row (the server enforces this anyway with a
clear error; UI just avoids surfacing a click that'd 400).
- "You're a member, not a manager" footer hint for non-owners/admins.
* Caller's role is derived from the members query (no extra
round-trip) — the membership row IS the source of truth for who
can manage what.
* Mutation errors surface inline at the page level with a Dismiss
action — kebab/copy actions that hit the last-owner guard, expired-
token error, etc. don't fail silently.
apps/web/app/invite/[token]/page.tsx (new):
* Public-by-token redeem page. Four phases handled cleanly:
1. No session yet -> "Sign in to continue" with callbackUrl set so
the user lands back here after auth.
2. Authenticated, accepting -> spinner.
3. Success -> redirect to the workspace's slug-rooted URL.
4. FORBIDDEN with cause.reason='email_not_owned' -> dedicated
explainer page showing both the invited email AND the user's
current sign-in email, with deep links to link the invited email
via OAuth and try again. (This is the Task 1 invariant
surfacing through the UI: we never silently accept an invite
under a mismatched identity.)
* All other accept errors (not found / revoked / expired) render the
message verbatim with a "Go home" button.
apps/web/server/trpc.ts:
* Added a small errorFormatter that exposes `error.cause` to the
client when it's a plain object. Required for the invite-accept
explainer page to read `cause.invitedEmail` off the TRPCError. The
cause-payload contract is "small, pure data, no secrets" — anything
the server throws as a cause is also visible client-side.
End-to-end behavior verified statically: type-check clean across all
6 packages. Smoke test path:
1. As admin@tasks.dev, open /<workspace>/teams.
2. Click Invite -> dialog opens -> enter an email, pick member, send.
3. See the success state with the accept URL. Copy it.
4. Open the URL in a different browser (or incognito). With no session
-> sign-in prompt. After auth -> invite accepts and you land in
the workspace. With a session whose email doesn't match -> the
email-mismatch explainer renders.
Note: the test runner shows three new tests in packages/shared
(invite-suggestions.test.ts) from the in-progress Task-3 subagent.
Those land with their own commit when the subagent finishes — they're
visible here only because they share the working tree.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 11:34:56 -04:00
|
|
|
{canManage ? <InviteDialog workspaceSlug={workspaceSlug} /> : null}
|
feat: ECHODO app shell, Coolify deploy, Authentik + Umami
Bundles in-flight ECHODO work with the Coolify deployment configuration:
App
- New routes: ai, forms, planner, settings (templates/types), teams,
doc detail, whiteboard detail
- New components: app shell rework (icon-rail, top-header), forms
builder/renderer/responses, types manager, objects creation dialog,
card primitive, form + overview views
- New tRPC routers: favorites, forms, types, workspaces; updates to
health and objects routers
- Markdown backlog sync (packages/database) + cursor-sync schema/migrations
- Schema additions: forms, types, favorites, markdown_backlog, cursor_sync
- Initial Drizzle migrations checked in
Deployment
- docker/docker-compose.coolify.yml: drops bundled Postgres/Redis
(uses CT 102 shared services), removes host port mappings, adds
Coolify SERVICE_FQDN_* magic vars for web + collab
- .env.example rewritten as the full ECHODO/Coolify variable manifest
- NextAuth gains an Authentik OIDC provider (gated on env presence)
- Root layout injects Umami tracking script when configured;
metadata title flipped to ECHODO
Security
- .gitignore expanded to exclude AGENT-DEPLOY.md, .env.*, secrets/,
credentials.*, *.key, *.crt, *.pem, ssh keys
Made-with: Cursor
2026-04-26 15:34:34 -04:00
|
|
|
</div>
|
|
|
|
|
|
feat(invites): invite dialog + accept route + teams UI (Task 2, part 2/2)
Closes Task-workspace-invites-and-roles end-to-end. Builds on the
schema + procedures from 7a55d6d (Task 2, part 1/2).
apps/web/components/teams/invite-dialog.tsx (new):
* Owner/admin-only sheet that wraps invites.create. Email input + role
select (member/admin; owner deliberately excluded — single-owner
model means ownership transfer is a separate flow, not a fresh
invite). On success surfaces the accept URL with a copy-to-clipboard
affordance and a "your email isn't wired up yet, paste this directly"
hint. Plain text input in this commit; the smart recipient
autocomplete combobox from Task 3 will swap it in via a follow-up
edit to this same file (subagent is working that in parallel).
apps/web/app/(app)/[workspaceSlug]/teams/page.tsx (rewrite):
* Replaced the placeholder "Invite coming soon" button with the new
InviteDialog. Adds:
- Pending invites section (admin/owner only) listing each open
invite with email, role, expiry-relative time, and Copy link /
Revoke actions.
- Per-member kebab menu with role-change actions and Remove. Only
owners can promote anyone to owner; admins can move people
between admin/member only. The "demote to member" item disables
on the last-owner row (the server enforces this anyway with a
clear error; UI just avoids surfacing a click that'd 400).
- "You're a member, not a manager" footer hint for non-owners/admins.
* Caller's role is derived from the members query (no extra
round-trip) — the membership row IS the source of truth for who
can manage what.
* Mutation errors surface inline at the page level with a Dismiss
action — kebab/copy actions that hit the last-owner guard, expired-
token error, etc. don't fail silently.
apps/web/app/invite/[token]/page.tsx (new):
* Public-by-token redeem page. Four phases handled cleanly:
1. No session yet -> "Sign in to continue" with callbackUrl set so
the user lands back here after auth.
2. Authenticated, accepting -> spinner.
3. Success -> redirect to the workspace's slug-rooted URL.
4. FORBIDDEN with cause.reason='email_not_owned' -> dedicated
explainer page showing both the invited email AND the user's
current sign-in email, with deep links to link the invited email
via OAuth and try again. (This is the Task 1 invariant
surfacing through the UI: we never silently accept an invite
under a mismatched identity.)
* All other accept errors (not found / revoked / expired) render the
message verbatim with a "Go home" button.
apps/web/server/trpc.ts:
* Added a small errorFormatter that exposes `error.cause` to the
client when it's a plain object. Required for the invite-accept
explainer page to read `cause.invitedEmail` off the TRPCError. The
cause-payload contract is "small, pure data, no secrets" — anything
the server throws as a cause is also visible client-side.
End-to-end behavior verified statically: type-check clean across all
6 packages. Smoke test path:
1. As admin@tasks.dev, open /<workspace>/teams.
2. Click Invite -> dialog opens -> enter an email, pick member, send.
3. See the success state with the accept URL. Copy it.
4. Open the URL in a different browser (or incognito). With no session
-> sign-in prompt. After auth -> invite accepts and you land in
the workspace. With a session whose email doesn't match -> the
email-mismatch explainer renders.
Note: the test runner shows three new tests in packages/shared
(invite-suggestions.test.ts) from the in-progress Task-3 subagent.
Those land with their own commit when the subagent finishes — they're
visible here only because they share the working tree.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 11:34:56 -04:00
|
|
|
{/* --- Pending invites (admin/owner only) --- */}
|
|
|
|
|
{canManage ? (
|
|
|
|
|
<section className="mb-10">
|
|
|
|
|
<div className="mb-3 flex items-center gap-2">
|
|
|
|
|
<MailCheck className="size-4 text-muted-foreground" aria-hidden />
|
|
|
|
|
<h2 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
|
|
|
|
Pending invites
|
|
|
|
|
</h2>
|
|
|
|
|
</div>
|
|
|
|
|
{invitesQuery.isLoading ? (
|
|
|
|
|
<p className="text-sm text-muted-foreground">Loading…</p>
|
|
|
|
|
) : !invitesQuery.data?.length ? (
|
|
|
|
|
<p className="text-sm text-muted-foreground">
|
|
|
|
|
No pending invites. Use the Invite button to add someone.
|
|
|
|
|
</p>
|
|
|
|
|
) : (
|
|
|
|
|
<ul className="space-y-2">
|
|
|
|
|
{invitesQuery.data.map((inv) => (
|
|
|
|
|
<li
|
|
|
|
|
key={inv.id}
|
|
|
|
|
className="flex items-center justify-between gap-4 rounded-md border border-border bg-card px-4 py-3 shadow-sm"
|
|
|
|
|
>
|
|
|
|
|
<div className="min-w-0">
|
|
|
|
|
<p className="truncate text-sm font-medium">{inv.email}</p>
|
|
|
|
|
<p className="text-xs text-muted-foreground">
|
|
|
|
|
Invited as {formatRoleLabel(inv.role)} ·{" "}
|
|
|
|
|
<span title={new Date(inv.expiresAt).toLocaleString()}>
|
|
|
|
|
expires {relativeFromNow(inv.expiresAt)}
|
|
|
|
|
</span>
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex shrink-0 items-center gap-2">
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => copyInviteLink(inv.token)}
|
|
|
|
|
>
|
|
|
|
|
<Copy className="size-3.5" aria-hidden />
|
|
|
|
|
{copyFlash === inv.token ? "Copied" : "Copy link"}
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => revokeMut.mutate({ inviteId: inv.id })}
|
|
|
|
|
disabled={revokeMut.isPending}
|
|
|
|
|
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
|
|
|
|
|
>
|
|
|
|
|
Revoke
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</li>
|
|
|
|
|
))}
|
|
|
|
|
</ul>
|
|
|
|
|
)}
|
|
|
|
|
</section>
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
{/* --- Members --- */}
|
|
|
|
|
<div className="mb-3 flex items-center gap-2">
|
|
|
|
|
<UserCog className="size-4 text-muted-foreground" aria-hidden />
|
|
|
|
|
<h2 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
|
|
|
|
Members
|
|
|
|
|
</h2>
|
|
|
|
|
</div>
|
|
|
|
|
{membersQuery.isLoading ? (
|
feat: ECHODO app shell, Coolify deploy, Authentik + Umami
Bundles in-flight ECHODO work with the Coolify deployment configuration:
App
- New routes: ai, forms, planner, settings (templates/types), teams,
doc detail, whiteboard detail
- New components: app shell rework (icon-rail, top-header), forms
builder/renderer/responses, types manager, objects creation dialog,
card primitive, form + overview views
- New tRPC routers: favorites, forms, types, workspaces; updates to
health and objects routers
- Markdown backlog sync (packages/database) + cursor-sync schema/migrations
- Schema additions: forms, types, favorites, markdown_backlog, cursor_sync
- Initial Drizzle migrations checked in
Deployment
- docker/docker-compose.coolify.yml: drops bundled Postgres/Redis
(uses CT 102 shared services), removes host port mappings, adds
Coolify SERVICE_FQDN_* magic vars for web + collab
- .env.example rewritten as the full ECHODO/Coolify variable manifest
- NextAuth gains an Authentik OIDC provider (gated on env presence)
- Root layout injects Umami tracking script when configured;
metadata title flipped to ECHODO
Security
- .gitignore expanded to exclude AGENT-DEPLOY.md, .env.*, secrets/,
credentials.*, *.key, *.crt, *.pem, ssh keys
Made-with: Cursor
2026-04-26 15:34:34 -04:00
|
|
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
|
|
|
{Array.from({ length: 6 }).map((_, i) => (
|
|
|
|
|
<MemberCardSkeleton key={i} />
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
feat(invites): invite dialog + accept route + teams UI (Task 2, part 2/2)
Closes Task-workspace-invites-and-roles end-to-end. Builds on the
schema + procedures from 7a55d6d (Task 2, part 1/2).
apps/web/components/teams/invite-dialog.tsx (new):
* Owner/admin-only sheet that wraps invites.create. Email input + role
select (member/admin; owner deliberately excluded — single-owner
model means ownership transfer is a separate flow, not a fresh
invite). On success surfaces the accept URL with a copy-to-clipboard
affordance and a "your email isn't wired up yet, paste this directly"
hint. Plain text input in this commit; the smart recipient
autocomplete combobox from Task 3 will swap it in via a follow-up
edit to this same file (subagent is working that in parallel).
apps/web/app/(app)/[workspaceSlug]/teams/page.tsx (rewrite):
* Replaced the placeholder "Invite coming soon" button with the new
InviteDialog. Adds:
- Pending invites section (admin/owner only) listing each open
invite with email, role, expiry-relative time, and Copy link /
Revoke actions.
- Per-member kebab menu with role-change actions and Remove. Only
owners can promote anyone to owner; admins can move people
between admin/member only. The "demote to member" item disables
on the last-owner row (the server enforces this anyway with a
clear error; UI just avoids surfacing a click that'd 400).
- "You're a member, not a manager" footer hint for non-owners/admins.
* Caller's role is derived from the members query (no extra
round-trip) — the membership row IS the source of truth for who
can manage what.
* Mutation errors surface inline at the page level with a Dismiss
action — kebab/copy actions that hit the last-owner guard, expired-
token error, etc. don't fail silently.
apps/web/app/invite/[token]/page.tsx (new):
* Public-by-token redeem page. Four phases handled cleanly:
1. No session yet -> "Sign in to continue" with callbackUrl set so
the user lands back here after auth.
2. Authenticated, accepting -> spinner.
3. Success -> redirect to the workspace's slug-rooted URL.
4. FORBIDDEN with cause.reason='email_not_owned' -> dedicated
explainer page showing both the invited email AND the user's
current sign-in email, with deep links to link the invited email
via OAuth and try again. (This is the Task 1 invariant
surfacing through the UI: we never silently accept an invite
under a mismatched identity.)
* All other accept errors (not found / revoked / expired) render the
message verbatim with a "Go home" button.
apps/web/server/trpc.ts:
* Added a small errorFormatter that exposes `error.cause` to the
client when it's a plain object. Required for the invite-accept
explainer page to read `cause.invitedEmail` off the TRPCError. The
cause-payload contract is "small, pure data, no secrets" — anything
the server throws as a cause is also visible client-side.
End-to-end behavior verified statically: type-check clean across all
6 packages. Smoke test path:
1. As admin@tasks.dev, open /<workspace>/teams.
2. Click Invite -> dialog opens -> enter an email, pick member, send.
3. See the success state with the accept URL. Copy it.
4. Open the URL in a different browser (or incognito). With no session
-> sign-in prompt. After auth -> invite accepts and you land in
the workspace. With a session whose email doesn't match -> the
email-mismatch explainer renders.
Note: the test runner shows three new tests in packages/shared
(invite-suggestions.test.ts) from the in-progress Task-3 subagent.
Those land with their own commit when the subagent finishes — they're
visible here only because they share the working tree.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 11:34:56 -04:00
|
|
|
) : !members.length ? (
|
|
|
|
|
<div className="rounded-lg border border-dashed border-border bg-muted/30 p-12 text-center text-sm text-muted-foreground">
|
feat: ECHODO app shell, Coolify deploy, Authentik + Umami
Bundles in-flight ECHODO work with the Coolify deployment configuration:
App
- New routes: ai, forms, planner, settings (templates/types), teams,
doc detail, whiteboard detail
- New components: app shell rework (icon-rail, top-header), forms
builder/renderer/responses, types manager, objects creation dialog,
card primitive, form + overview views
- New tRPC routers: favorites, forms, types, workspaces; updates to
health and objects routers
- Markdown backlog sync (packages/database) + cursor-sync schema/migrations
- Schema additions: forms, types, favorites, markdown_backlog, cursor_sync
- Initial Drizzle migrations checked in
Deployment
- docker/docker-compose.coolify.yml: drops bundled Postgres/Redis
(uses CT 102 shared services), removes host port mappings, adds
Coolify SERVICE_FQDN_* magic vars for web + collab
- .env.example rewritten as the full ECHODO/Coolify variable manifest
- NextAuth gains an Authentik OIDC provider (gated on env presence)
- Root layout injects Umami tracking script when configured;
metadata title flipped to ECHODO
Security
- .gitignore expanded to exclude AGENT-DEPLOY.md, .env.*, secrets/,
credentials.*, *.key, *.crt, *.pem, ssh keys
Made-with: Cursor
2026-04-26 15:34:34 -04:00
|
|
|
No members in this workspace yet.
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
|
|
|
{members.map((m) => {
|
|
|
|
|
const displayName = m.name?.trim() || m.email;
|
feat(invites): invite dialog + accept route + teams UI (Task 2, part 2/2)
Closes Task-workspace-invites-and-roles end-to-end. Builds on the
schema + procedures from 7a55d6d (Task 2, part 1/2).
apps/web/components/teams/invite-dialog.tsx (new):
* Owner/admin-only sheet that wraps invites.create. Email input + role
select (member/admin; owner deliberately excluded — single-owner
model means ownership transfer is a separate flow, not a fresh
invite). On success surfaces the accept URL with a copy-to-clipboard
affordance and a "your email isn't wired up yet, paste this directly"
hint. Plain text input in this commit; the smart recipient
autocomplete combobox from Task 3 will swap it in via a follow-up
edit to this same file (subagent is working that in parallel).
apps/web/app/(app)/[workspaceSlug]/teams/page.tsx (rewrite):
* Replaced the placeholder "Invite coming soon" button with the new
InviteDialog. Adds:
- Pending invites section (admin/owner only) listing each open
invite with email, role, expiry-relative time, and Copy link /
Revoke actions.
- Per-member kebab menu with role-change actions and Remove. Only
owners can promote anyone to owner; admins can move people
between admin/member only. The "demote to member" item disables
on the last-owner row (the server enforces this anyway with a
clear error; UI just avoids surfacing a click that'd 400).
- "You're a member, not a manager" footer hint for non-owners/admins.
* Caller's role is derived from the members query (no extra
round-trip) — the membership row IS the source of truth for who
can manage what.
* Mutation errors surface inline at the page level with a Dismiss
action — kebab/copy actions that hit the last-owner guard, expired-
token error, etc. don't fail silently.
apps/web/app/invite/[token]/page.tsx (new):
* Public-by-token redeem page. Four phases handled cleanly:
1. No session yet -> "Sign in to continue" with callbackUrl set so
the user lands back here after auth.
2. Authenticated, accepting -> spinner.
3. Success -> redirect to the workspace's slug-rooted URL.
4. FORBIDDEN with cause.reason='email_not_owned' -> dedicated
explainer page showing both the invited email AND the user's
current sign-in email, with deep links to link the invited email
via OAuth and try again. (This is the Task 1 invariant
surfacing through the UI: we never silently accept an invite
under a mismatched identity.)
* All other accept errors (not found / revoked / expired) render the
message verbatim with a "Go home" button.
apps/web/server/trpc.ts:
* Added a small errorFormatter that exposes `error.cause` to the
client when it's a plain object. Required for the invite-accept
explainer page to read `cause.invitedEmail` off the TRPCError. The
cause-payload contract is "small, pure data, no secrets" — anything
the server throws as a cause is also visible client-side.
End-to-end behavior verified statically: type-check clean across all
6 packages. Smoke test path:
1. As admin@tasks.dev, open /<workspace>/teams.
2. Click Invite -> dialog opens -> enter an email, pick member, send.
3. See the success state with the accept URL. Copy it.
4. Open the URL in a different browser (or incognito). With no session
-> sign-in prompt. After auth -> invite accepts and you land in
the workspace. With a session whose email doesn't match -> the
email-mismatch explainer renders.
Note: the test runner shows three new tests in packages/shared
(invite-suggestions.test.ts) from the in-progress Task-3 subagent.
Those land with their own commit when the subagent finishes — they're
visible here only because they share the working tree.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 11:34:56 -04:00
|
|
|
const isSelf = m.id === currentUserId;
|
|
|
|
|
const isLastOwner = m.role === "owner" && ownerCount <= 1;
|
|
|
|
|
const showActions = canManage && !isSelf;
|
feat: ECHODO app shell, Coolify deploy, Authentik + Umami
Bundles in-flight ECHODO work with the Coolify deployment configuration:
App
- New routes: ai, forms, planner, settings (templates/types), teams,
doc detail, whiteboard detail
- New components: app shell rework (icon-rail, top-header), forms
builder/renderer/responses, types manager, objects creation dialog,
card primitive, form + overview views
- New tRPC routers: favorites, forms, types, workspaces; updates to
health and objects routers
- Markdown backlog sync (packages/database) + cursor-sync schema/migrations
- Schema additions: forms, types, favorites, markdown_backlog, cursor_sync
- Initial Drizzle migrations checked in
Deployment
- docker/docker-compose.coolify.yml: drops bundled Postgres/Redis
(uses CT 102 shared services), removes host port mappings, adds
Coolify SERVICE_FQDN_* magic vars for web + collab
- .env.example rewritten as the full ECHODO/Coolify variable manifest
- NextAuth gains an Authentik OIDC provider (gated on env presence)
- Root layout injects Umami tracking script when configured;
metadata title flipped to ECHODO
Security
- .gitignore expanded to exclude AGENT-DEPLOY.md, .env.*, secrets/,
credentials.*, *.key, *.crt, *.pem, ssh keys
Made-with: Cursor
2026-04-26 15:34:34 -04:00
|
|
|
return (
|
|
|
|
|
<Card key={m.id} className="overflow-hidden">
|
|
|
|
|
<CardContent className="flex items-center gap-4 p-6">
|
|
|
|
|
<Avatar className="size-10 shrink-0">
|
feat(invites): invite dialog + accept route + teams UI (Task 2, part 2/2)
Closes Task-workspace-invites-and-roles end-to-end. Builds on the
schema + procedures from 7a55d6d (Task 2, part 1/2).
apps/web/components/teams/invite-dialog.tsx (new):
* Owner/admin-only sheet that wraps invites.create. Email input + role
select (member/admin; owner deliberately excluded — single-owner
model means ownership transfer is a separate flow, not a fresh
invite). On success surfaces the accept URL with a copy-to-clipboard
affordance and a "your email isn't wired up yet, paste this directly"
hint. Plain text input in this commit; the smart recipient
autocomplete combobox from Task 3 will swap it in via a follow-up
edit to this same file (subagent is working that in parallel).
apps/web/app/(app)/[workspaceSlug]/teams/page.tsx (rewrite):
* Replaced the placeholder "Invite coming soon" button with the new
InviteDialog. Adds:
- Pending invites section (admin/owner only) listing each open
invite with email, role, expiry-relative time, and Copy link /
Revoke actions.
- Per-member kebab menu with role-change actions and Remove. Only
owners can promote anyone to owner; admins can move people
between admin/member only. The "demote to member" item disables
on the last-owner row (the server enforces this anyway with a
clear error; UI just avoids surfacing a click that'd 400).
- "You're a member, not a manager" footer hint for non-owners/admins.
* Caller's role is derived from the members query (no extra
round-trip) — the membership row IS the source of truth for who
can manage what.
* Mutation errors surface inline at the page level with a Dismiss
action — kebab/copy actions that hit the last-owner guard, expired-
token error, etc. don't fail silently.
apps/web/app/invite/[token]/page.tsx (new):
* Public-by-token redeem page. Four phases handled cleanly:
1. No session yet -> "Sign in to continue" with callbackUrl set so
the user lands back here after auth.
2. Authenticated, accepting -> spinner.
3. Success -> redirect to the workspace's slug-rooted URL.
4. FORBIDDEN with cause.reason='email_not_owned' -> dedicated
explainer page showing both the invited email AND the user's
current sign-in email, with deep links to link the invited email
via OAuth and try again. (This is the Task 1 invariant
surfacing through the UI: we never silently accept an invite
under a mismatched identity.)
* All other accept errors (not found / revoked / expired) render the
message verbatim with a "Go home" button.
apps/web/server/trpc.ts:
* Added a small errorFormatter that exposes `error.cause` to the
client when it's a plain object. Required for the invite-accept
explainer page to read `cause.invitedEmail` off the TRPCError. The
cause-payload contract is "small, pure data, no secrets" — anything
the server throws as a cause is also visible client-side.
End-to-end behavior verified statically: type-check clean across all
6 packages. Smoke test path:
1. As admin@tasks.dev, open /<workspace>/teams.
2. Click Invite -> dialog opens -> enter an email, pick member, send.
3. See the success state with the accept URL. Copy it.
4. Open the URL in a different browser (or incognito). With no session
-> sign-in prompt. After auth -> invite accepts and you land in
the workspace. With a session whose email doesn't match -> the
email-mismatch explainer renders.
Note: the test runner shows three new tests in packages/shared
(invite-suggestions.test.ts) from the in-progress Task-3 subagent.
Those land with their own commit when the subagent finishes — they're
visible here only because they share the working tree.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 11:34:56 -04:00
|
|
|
{m.avatarUrl ? <AvatarImage src={m.avatarUrl} alt="" /> : null}
|
|
|
|
|
<AvatarFallback>
|
|
|
|
|
{memberInitials(m.name, m.email)}
|
|
|
|
|
</AvatarFallback>
|
feat: ECHODO app shell, Coolify deploy, Authentik + Umami
Bundles in-flight ECHODO work with the Coolify deployment configuration:
App
- New routes: ai, forms, planner, settings (templates/types), teams,
doc detail, whiteboard detail
- New components: app shell rework (icon-rail, top-header), forms
builder/renderer/responses, types manager, objects creation dialog,
card primitive, form + overview views
- New tRPC routers: favorites, forms, types, workspaces; updates to
health and objects routers
- Markdown backlog sync (packages/database) + cursor-sync schema/migrations
- Schema additions: forms, types, favorites, markdown_backlog, cursor_sync
- Initial Drizzle migrations checked in
Deployment
- docker/docker-compose.coolify.yml: drops bundled Postgres/Redis
(uses CT 102 shared services), removes host port mappings, adds
Coolify SERVICE_FQDN_* magic vars for web + collab
- .env.example rewritten as the full ECHODO/Coolify variable manifest
- NextAuth gains an Authentik OIDC provider (gated on env presence)
- Root layout injects Umami tracking script when configured;
metadata title flipped to ECHODO
Security
- .gitignore expanded to exclude AGENT-DEPLOY.md, .env.*, secrets/,
credentials.*, *.key, *.crt, *.pem, ssh keys
Made-with: Cursor
2026-04-26 15:34:34 -04:00
|
|
|
</Avatar>
|
|
|
|
|
<div className="min-w-0 flex-1">
|
|
|
|
|
<p className="truncate font-medium">{displayName}</p>
|
|
|
|
|
<p className="truncate text-sm text-muted-foreground">{m.email}</p>
|
|
|
|
|
</div>
|
|
|
|
|
<Badge variant="secondary" className="shrink-0 capitalize">
|
|
|
|
|
{formatRoleLabel(m.role)}
|
|
|
|
|
</Badge>
|
feat(invites): invite dialog + accept route + teams UI (Task 2, part 2/2)
Closes Task-workspace-invites-and-roles end-to-end. Builds on the
schema + procedures from 7a55d6d (Task 2, part 1/2).
apps/web/components/teams/invite-dialog.tsx (new):
* Owner/admin-only sheet that wraps invites.create. Email input + role
select (member/admin; owner deliberately excluded — single-owner
model means ownership transfer is a separate flow, not a fresh
invite). On success surfaces the accept URL with a copy-to-clipboard
affordance and a "your email isn't wired up yet, paste this directly"
hint. Plain text input in this commit; the smart recipient
autocomplete combobox from Task 3 will swap it in via a follow-up
edit to this same file (subagent is working that in parallel).
apps/web/app/(app)/[workspaceSlug]/teams/page.tsx (rewrite):
* Replaced the placeholder "Invite coming soon" button with the new
InviteDialog. Adds:
- Pending invites section (admin/owner only) listing each open
invite with email, role, expiry-relative time, and Copy link /
Revoke actions.
- Per-member kebab menu with role-change actions and Remove. Only
owners can promote anyone to owner; admins can move people
between admin/member only. The "demote to member" item disables
on the last-owner row (the server enforces this anyway with a
clear error; UI just avoids surfacing a click that'd 400).
- "You're a member, not a manager" footer hint for non-owners/admins.
* Caller's role is derived from the members query (no extra
round-trip) — the membership row IS the source of truth for who
can manage what.
* Mutation errors surface inline at the page level with a Dismiss
action — kebab/copy actions that hit the last-owner guard, expired-
token error, etc. don't fail silently.
apps/web/app/invite/[token]/page.tsx (new):
* Public-by-token redeem page. Four phases handled cleanly:
1. No session yet -> "Sign in to continue" with callbackUrl set so
the user lands back here after auth.
2. Authenticated, accepting -> spinner.
3. Success -> redirect to the workspace's slug-rooted URL.
4. FORBIDDEN with cause.reason='email_not_owned' -> dedicated
explainer page showing both the invited email AND the user's
current sign-in email, with deep links to link the invited email
via OAuth and try again. (This is the Task 1 invariant
surfacing through the UI: we never silently accept an invite
under a mismatched identity.)
* All other accept errors (not found / revoked / expired) render the
message verbatim with a "Go home" button.
apps/web/server/trpc.ts:
* Added a small errorFormatter that exposes `error.cause` to the
client when it's a plain object. Required for the invite-accept
explainer page to read `cause.invitedEmail` off the TRPCError. The
cause-payload contract is "small, pure data, no secrets" — anything
the server throws as a cause is also visible client-side.
End-to-end behavior verified statically: type-check clean across all
6 packages. Smoke test path:
1. As admin@tasks.dev, open /<workspace>/teams.
2. Click Invite -> dialog opens -> enter an email, pick member, send.
3. See the success state with the accept URL. Copy it.
4. Open the URL in a different browser (or incognito). With no session
-> sign-in prompt. After auth -> invite accepts and you land in
the workspace. With a session whose email doesn't match -> the
email-mismatch explainer renders.
Note: the test runner shows three new tests in packages/shared
(invite-suggestions.test.ts) from the in-progress Task-3 subagent.
Those land with their own commit when the subagent finishes — they're
visible here only because they share the working tree.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 11:34:56 -04:00
|
|
|
{showActions ? (
|
|
|
|
|
<DropdownMenu>
|
|
|
|
|
<DropdownMenuTrigger asChild>
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="icon"
|
|
|
|
|
aria-label={`Manage ${displayName}`}
|
|
|
|
|
className="size-8"
|
|
|
|
|
>
|
|
|
|
|
<MoreHorizontal className="size-4" aria-hidden />
|
|
|
|
|
</Button>
|
|
|
|
|
</DropdownMenuTrigger>
|
|
|
|
|
<DropdownMenuContent align="end" className="min-w-44">
|
|
|
|
|
<DropdownMenuLabel className="text-xs text-muted-foreground">
|
|
|
|
|
Change role
|
|
|
|
|
</DropdownMenuLabel>
|
|
|
|
|
{/* Only an owner can promote anyone to owner. */}
|
|
|
|
|
{callerRole === "owner" ? (
|
|
|
|
|
<DropdownMenuItem
|
|
|
|
|
disabled={m.role === "owner" || updateRoleMut.isPending}
|
|
|
|
|
onSelect={() =>
|
|
|
|
|
updateRoleMut.mutate({
|
|
|
|
|
workspace: workspaceSlug,
|
|
|
|
|
userId: m.id,
|
|
|
|
|
role: "owner",
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
Owner
|
|
|
|
|
</DropdownMenuItem>
|
|
|
|
|
) : null}
|
|
|
|
|
<DropdownMenuItem
|
|
|
|
|
disabled={m.role === "admin" || updateRoleMut.isPending}
|
|
|
|
|
onSelect={() =>
|
|
|
|
|
updateRoleMut.mutate({
|
|
|
|
|
workspace: workspaceSlug,
|
|
|
|
|
userId: m.id,
|
|
|
|
|
role: "admin",
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
Admin
|
|
|
|
|
</DropdownMenuItem>
|
|
|
|
|
<DropdownMenuItem
|
|
|
|
|
disabled={
|
|
|
|
|
m.role === "member" ||
|
|
|
|
|
isLastOwner ||
|
|
|
|
|
updateRoleMut.isPending
|
|
|
|
|
}
|
|
|
|
|
onSelect={() =>
|
|
|
|
|
updateRoleMut.mutate({
|
|
|
|
|
workspace: workspaceSlug,
|
|
|
|
|
userId: m.id,
|
|
|
|
|
role: "member",
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
Member
|
|
|
|
|
</DropdownMenuItem>
|
|
|
|
|
<DropdownMenuSeparator />
|
|
|
|
|
<DropdownMenuItem
|
|
|
|
|
disabled={isLastOwner || removeMemberMut.isPending}
|
|
|
|
|
onSelect={() => {
|
|
|
|
|
if (
|
|
|
|
|
window.confirm(
|
|
|
|
|
`Remove ${displayName} from this workspace?`,
|
|
|
|
|
)
|
|
|
|
|
) {
|
|
|
|
|
removeMemberMut.mutate({
|
|
|
|
|
workspace: workspaceSlug,
|
|
|
|
|
userId: m.id,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
className="text-destructive focus:bg-destructive/10 focus:text-destructive"
|
|
|
|
|
>
|
|
|
|
|
<Trash2 className="size-3.5" aria-hidden />
|
|
|
|
|
Remove
|
|
|
|
|
</DropdownMenuItem>
|
|
|
|
|
</DropdownMenuContent>
|
|
|
|
|
</DropdownMenu>
|
|
|
|
|
) : null}
|
feat: ECHODO app shell, Coolify deploy, Authentik + Umami
Bundles in-flight ECHODO work with the Coolify deployment configuration:
App
- New routes: ai, forms, planner, settings (templates/types), teams,
doc detail, whiteboard detail
- New components: app shell rework (icon-rail, top-header), forms
builder/renderer/responses, types manager, objects creation dialog,
card primitive, form + overview views
- New tRPC routers: favorites, forms, types, workspaces; updates to
health and objects routers
- Markdown backlog sync (packages/database) + cursor-sync schema/migrations
- Schema additions: forms, types, favorites, markdown_backlog, cursor_sync
- Initial Drizzle migrations checked in
Deployment
- docker/docker-compose.coolify.yml: drops bundled Postgres/Redis
(uses CT 102 shared services), removes host port mappings, adds
Coolify SERVICE_FQDN_* magic vars for web + collab
- .env.example rewritten as the full ECHODO/Coolify variable manifest
- NextAuth gains an Authentik OIDC provider (gated on env presence)
- Root layout injects Umami tracking script when configured;
metadata title flipped to ECHODO
Security
- .gitignore expanded to exclude AGENT-DEPLOY.md, .env.*, secrets/,
credentials.*, *.key, *.crt, *.pem, ssh keys
Made-with: Cursor
2026-04-26 15:34:34 -04:00
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
feat(invites): invite dialog + accept route + teams UI (Task 2, part 2/2)
Closes Task-workspace-invites-and-roles end-to-end. Builds on the
schema + procedures from 7a55d6d (Task 2, part 1/2).
apps/web/components/teams/invite-dialog.tsx (new):
* Owner/admin-only sheet that wraps invites.create. Email input + role
select (member/admin; owner deliberately excluded — single-owner
model means ownership transfer is a separate flow, not a fresh
invite). On success surfaces the accept URL with a copy-to-clipboard
affordance and a "your email isn't wired up yet, paste this directly"
hint. Plain text input in this commit; the smart recipient
autocomplete combobox from Task 3 will swap it in via a follow-up
edit to this same file (subagent is working that in parallel).
apps/web/app/(app)/[workspaceSlug]/teams/page.tsx (rewrite):
* Replaced the placeholder "Invite coming soon" button with the new
InviteDialog. Adds:
- Pending invites section (admin/owner only) listing each open
invite with email, role, expiry-relative time, and Copy link /
Revoke actions.
- Per-member kebab menu with role-change actions and Remove. Only
owners can promote anyone to owner; admins can move people
between admin/member only. The "demote to member" item disables
on the last-owner row (the server enforces this anyway with a
clear error; UI just avoids surfacing a click that'd 400).
- "You're a member, not a manager" footer hint for non-owners/admins.
* Caller's role is derived from the members query (no extra
round-trip) — the membership row IS the source of truth for who
can manage what.
* Mutation errors surface inline at the page level with a Dismiss
action — kebab/copy actions that hit the last-owner guard, expired-
token error, etc. don't fail silently.
apps/web/app/invite/[token]/page.tsx (new):
* Public-by-token redeem page. Four phases handled cleanly:
1. No session yet -> "Sign in to continue" with callbackUrl set so
the user lands back here after auth.
2. Authenticated, accepting -> spinner.
3. Success -> redirect to the workspace's slug-rooted URL.
4. FORBIDDEN with cause.reason='email_not_owned' -> dedicated
explainer page showing both the invited email AND the user's
current sign-in email, with deep links to link the invited email
via OAuth and try again. (This is the Task 1 invariant
surfacing through the UI: we never silently accept an invite
under a mismatched identity.)
* All other accept errors (not found / revoked / expired) render the
message verbatim with a "Go home" button.
apps/web/server/trpc.ts:
* Added a small errorFormatter that exposes `error.cause` to the
client when it's a plain object. Required for the invite-accept
explainer page to read `cause.invitedEmail` off the TRPCError. The
cause-payload contract is "small, pure data, no secrets" — anything
the server throws as a cause is also visible client-side.
End-to-end behavior verified statically: type-check clean across all
6 packages. Smoke test path:
1. As admin@tasks.dev, open /<workspace>/teams.
2. Click Invite -> dialog opens -> enter an email, pick member, send.
3. See the success state with the accept URL. Copy it.
4. Open the URL in a different browser (or incognito). With no session
-> sign-in prompt. After auth -> invite accepts and you land in
the workspace. With a session whose email doesn't match -> the
email-mismatch explainer renders.
Note: the test runner shows three new tests in packages/shared
(invite-suggestions.test.ts) from the in-progress Task-3 subagent.
Those land with their own commit when the subagent finishes — they're
visible here only because they share the working tree.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 11:34:56 -04:00
|
|
|
|
|
|
|
|
{/* Surface any mutation errors at the page level so kebab/dialog actions
|
|
|
|
|
that hit the last-owner guard or other validation failures don't
|
|
|
|
|
fail silently. */}
|
|
|
|
|
{(updateRoleMut.error ||
|
|
|
|
|
removeMemberMut.error ||
|
|
|
|
|
revokeMut.error) ? (
|
|
|
|
|
<p className="mt-6 text-sm text-destructive" role="alert">
|
|
|
|
|
{updateRoleMut.error?.message ??
|
|
|
|
|
removeMemberMut.error?.message ??
|
|
|
|
|
revokeMut.error?.message}
|
|
|
|
|
{(updateRoleMut.error ||
|
|
|
|
|
removeMemberMut.error ||
|
|
|
|
|
revokeMut.error) && (
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
className="ml-2"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
updateRoleMut.reset();
|
|
|
|
|
removeMemberMut.reset();
|
|
|
|
|
revokeMut.reset();
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
Dismiss
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
</p>
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
{!canManage && callerRole === "member" ? (
|
|
|
|
|
<p className="mt-10 rounded-md bg-muted/40 px-3 py-2 text-xs text-muted-foreground">
|
|
|
|
|
You can see the team but only owners and admins can invite or change
|
|
|
|
|
roles. Ask one of them if you need a change.
|
|
|
|
|
</p>
|
|
|
|
|
) : null}
|
feat: ECHODO app shell, Coolify deploy, Authentik + Umami
Bundles in-flight ECHODO work with the Coolify deployment configuration:
App
- New routes: ai, forms, planner, settings (templates/types), teams,
doc detail, whiteboard detail
- New components: app shell rework (icon-rail, top-header), forms
builder/renderer/responses, types manager, objects creation dialog,
card primitive, form + overview views
- New tRPC routers: favorites, forms, types, workspaces; updates to
health and objects routers
- Markdown backlog sync (packages/database) + cursor-sync schema/migrations
- Schema additions: forms, types, favorites, markdown_backlog, cursor_sync
- Initial Drizzle migrations checked in
Deployment
- docker/docker-compose.coolify.yml: drops bundled Postgres/Redis
(uses CT 102 shared services), removes host port mappings, adds
Coolify SERVICE_FQDN_* magic vars for web + collab
- .env.example rewritten as the full ECHODO/Coolify variable manifest
- NextAuth gains an Authentik OIDC provider (gated on env presence)
- Root layout injects Umami tracking script when configured;
metadata title flipped to ECHODO
Security
- .gitignore expanded to exclude AGENT-DEPLOY.md, .env.*, secrets/,
credentials.*, *.key, *.crt, *.pem, ssh keys
Made-with: Cursor
2026-04-26 15:34:34 -04:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|