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
13 changes: 13 additions & 0 deletions public/brands/polinetwork-mono.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
96 changes: 62 additions & 34 deletions src/components/dev-login.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useEffect, useId, useState } from "react";
import { FlaskConical, TriangleAlert } from "lucide-react";
import { cn } from "cn";
import { buttonVariants } from "@/components/ui/button";
Expand All @@ -20,6 +20,8 @@ import {
export function DevLoginPanel({ redirect }: { redirect: string }) {
const [personas, setPersonas] = useState<DevPersonaSummary[]>([]);
const [oauthQuery, setOauthQuery] = useState<string>();
const [open, setOpen] = useState(false);
const listId = useId();

useEffect(() => {
// Read from the address bar as Better Auth's client does, so the signature still matches.
Expand All @@ -40,41 +42,67 @@ export function DevLoginPanel({ redirect }: { redirect: string }) {
return (
<section
aria-labelledby="dev-login-title"
className="space-y-3 rounded-xl border border-dashed border-amber-500/60 bg-amber-500/5 p-4"
className={cn(
"absolute top-6 left-6 z-10 overflow-hidden rounded-xl border border-dashed border-amber-500/60 bg-amber-500/5 transition-[width] duration-300 ease-out motion-reduce:transition-none",
open ? "w-[min(24rem,calc(100vw-3rem))]" : "w-12",
)}
>
<div>
<h2 id="dev-login-title" className="flex items-center gap-1.5 text-sm font-semibold">
<FlaskConical className="size-4 text-amber-600 dark:text-amber-400" aria-hidden="true" />
Dev sign-in
</h2>
<p className="mt-1 text-xs leading-5 text-muted-foreground">
Local development only. Signs you in as a test person, no account needed.
</p>
<h2 id="dev-login-title">
<button
type="button"
aria-label={open ? "Collapse dev sign-in" : "Open dev sign-in"}
aria-expanded={open}
aria-controls={listId}
onClick={() => setOpen((value) => !value)}
className="flex h-12 w-full items-center gap-1.5 px-3.5 text-left text-sm font-semibold whitespace-nowrap outline-none hover:bg-amber-500/10 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
>
<FlaskConical
className="size-4 shrink-0 text-amber-600 dark:text-amber-400"
aria-hidden="true"
/>
<span
aria-hidden="true"
className={cn(
"transition-opacity duration-200 motion-reduce:transition-none",
open ? "opacity-100" : "opacity-0",
)}
>
Dev sign-in
</span>
</button>
</h2>
<div
id={listId}
inert={!open}
className={cn(
"grid transition-[grid-template-rows] duration-300 ease-out motion-reduce:transition-none",
open ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
)}
>
<div className="min-h-0 overflow-hidden">
<ul className="space-y-2 px-4 pt-1 pb-4">
{personas.map((persona) => (
<li key={persona.key}>
<a
href={devLoginHref(persona.key, redirect, oauthQuery)}
className={cn(
buttonVariants({ variant: "outline" }),
"h-auto w-full flex-col items-start gap-0.5 bg-card px-3 py-2 text-left whitespace-normal",
)}
>
<span className="text-xs font-normal text-foreground">{persona.description}</span>
{persona.warning && (
<span className="flex items-start gap-1 text-xs font-normal text-amber-700 dark:text-amber-300">
<TriangleAlert className="mt-0.5 size-3 shrink-0" aria-hidden="true" />
{persona.warning}
</span>
)}
</a>
</li>
))}
</ul>
</div>
</div>
<ul className="space-y-2">
{personas.map((persona) => (
<li key={persona.key}>
<a
href={devLoginHref(persona.key, redirect, oauthQuery)}
className={cn(
buttonVariants({ variant: "outline" }),
"h-auto w-full flex-col items-start gap-0.5 bg-card px-3 py-2 text-left whitespace-normal",
)}
>
<span className="text-sm font-medium">{persona.name}</span>
<span className="text-xs font-normal text-muted-foreground">
{persona.description}
</span>
{persona.warning && (
<span className="flex items-start gap-1 text-xs font-normal text-amber-700 dark:text-amber-300">
<TriangleAlert className="mt-0.5 size-3 shrink-0" aria-hidden="true" />
{persona.warning}
</span>
)}
</a>
</li>
))}
</ul>
</section>
);
}
17 changes: 12 additions & 5 deletions src/components/login-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ const providers = [

export function LoginLayout({ children, wide = false }: { children: ReactNode; wide?: boolean }) {
return (
<div data-login-layout className="flex min-h-svh flex-col bg-muted">
<div data-login-layout className="flex flex-1 flex-col bg-muted">
<header className="flex justify-end p-5 sm:p-6">
<ThemeSwitch />
</header>
Expand All @@ -37,7 +37,6 @@ export function LoginLayout({ children, wide = false }: { children: ReactNode; w
</span>
</a>
{children}
<p className="text-center text-xs leading-5 text-muted-foreground">PoliNetwork APS</p>
</div>
</main>
</div>
Expand Down Expand Up @@ -225,10 +224,18 @@ export function LoginPage({ callbackURL = "/" }: { callbackURL?: string }) {
</p>
)}
</div>

<p className="text-center text-xs leading-5 text-muted-foreground">
{oauthFlow
? "After signing in you'll review what the application can access before anything is shared."
: "New here? Continue with an account to get started. You can add a passkey once you're signed in."}
By continuing, you acknowledge that you have read and understood our{" "}
<a
href="https://polinetwork.org/privacy"
aria-label="privacy policy"
rel="noreferrer noopener"
className="underline"
target="_blank"
>
Privacy Policy.
</a>
</p>
</CardContent>
</Card>
Expand Down
90 changes: 59 additions & 31 deletions src/components/rbac/catalog-search.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,62 @@
import { useRef, type ReactNode } from "react";
import { Search, X } from "lucide-react";
import { cn } from "cn";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";

/** The search box on its own, for pages that lay out the rest of the toolbar themselves. */
export function SearchField({
kind,
query,
onQueryChange,
className,
inputClassName,
}: {
kind: "roles" | "permissions";
query: string;
onQueryChange: (query: string) => void;
className?: string;
inputClassName?: string;
}) {
const input = useRef<HTMLInputElement>(null);

return (
<div className={cn("relative min-w-0", className)}>
<Search
className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground"
aria-hidden="true"
/>
<Input
ref={input}
type="search"
value={query}
onChange={(event) => onQueryChange(event.target.value)}
placeholder={`Search ${kind}鈥}
aria-label={`Search ${kind}`}
className={cn(
"pr-10 pl-9 [&::-webkit-search-cancel-button]:appearance-none",
inputClassName,
)}
/>
{query && (
<Button
variant="ghost"
size="icon"
className="absolute top-1/2 right-1 size-7 -translate-y-1/2 text-muted-foreground"
aria-label="Clear search"
onClick={() => {
onQueryChange("");
input.current?.focus();
}}
>
<X className="size-3.5" aria-hidden="true" />
</Button>
)}
</div>
);
}

export function CatalogSearch({
kind,
query,
Expand All @@ -19,40 +72,15 @@ export function CatalogSearch({
total: number;
children?: ReactNode;
}) {
const input = useRef<HTMLInputElement>(null);

return (
<Card className="flex flex-wrap items-center gap-3 p-5">
<div className="flex min-w-0 flex-1 basis-64 flex-wrap items-center gap-3">
<div className="relative min-w-0 flex-1 basis-56 sm:max-w-lg">
<Search
className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground"
aria-hidden="true"
/>
<Input
ref={input}
type="search"
value={query}
onChange={(event) => onQueryChange(event.target.value)}
placeholder={`Search ${kind}鈥}
aria-label={`Search ${kind}`}
className="pr-10 pl-9 [&::-webkit-search-cancel-button]:appearance-none"
/>
{query && (
<Button
variant="ghost"
size="icon"
className="absolute top-1/2 right-1 size-7 -translate-y-1/2 text-muted-foreground"
aria-label="Clear search"
onClick={() => {
onQueryChange("");
input.current?.focus();
}}
>
<X className="size-3.5" aria-hidden="true" />
</Button>
)}
</div>
<SearchField
kind={kind}
query={query}
onQueryChange={onQueryChange}
className="flex-1 basis-56 sm:max-w-lg"
/>
<p role="status" className="text-xs whitespace-nowrap text-muted-foreground">
{query.trim() ? `${count} of ${total}` : total} {kind}
</p>
Expand Down
48 changes: 48 additions & 0 deletions src/components/rbac/icons.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { CircleQuestionMark, Crown, KeyRound, type LucideProps, Plus, Users } from "lucide-react";
import { cn } from "cn";

/*
* The eRBACo designs draw lucide's glyphs with their own line widths, given in pixels at the size
* each icon renders. `absoluteStrokeWidth` keeps them at those pixels, which is why every icon
* here takes a fixed `size` rather than a size class.
*/

type IconProps = Omit<LucideProps, "size" | "strokeWidth" | "absoluteStrokeWidth">;

export function PermissionsIcon({ className, ...props }: IconProps) {
// The design's key hole is a heavier dot than the outline around it.
return (
<KeyRound
size={18}
strokeWidth={1}
absoluteStrokeWidth
aria-hidden="true"
className={cn("[&_circle]:[stroke-width:2.667]", className)}
{...props}
/>
);
}

export function MembersIcon(props: IconProps) {
return <Users size={18} strokeWidth={1} absoluteStrokeWidth aria-hidden="true" {...props} />;
}

export function MasterAdminIcon(props: IconProps) {
return <Crown size={42} strokeWidth={2} absoluteStrokeWidth aria-hidden="true" {...props} />;
}

export function HelpIcon(props: IconProps) {
return (
<CircleQuestionMark
size={20}
strokeWidth={1.5}
absoluteStrokeWidth
aria-hidden="true"
{...props}
/>
);
}

export function NewIcon(props: IconProps) {
return <Plus size={16} strokeWidth={2} absoluteStrokeWidth aria-hidden="true" {...props} />;
}
2 changes: 1 addition & 1 deletion src/components/route-error.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export function SectionShell({
children: ReactNode;
}) {
return (
<div className="min-h-screen">
<div className="flex-1">
<AppHeader active={active} />
<main className="mx-auto max-w-6xl px-5 py-10 sm:px-8 sm:py-14">{children}</main>
</div>
Expand Down
14 changes: 7 additions & 7 deletions src/dev/personas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,15 +78,15 @@ export const DEV_PERSONAS: DevPersona[] = [
id: DEV_ADMIN_ID,
name: "Ada Admin",
email: "admin@polinetwork.test",
description: "Master Admin: every permission",
description: "Master Admin",
accounts: [{ providerId: "google", accountId: "dev-admin" }],
},
{
key: "staff",
id: "dev-staff",
name: "Sam Staff",
email: "staff@polinetwork.test",
description: "Can read the user directory and roles, nothing else",
description: "Can read the user directory and roles",
accounts: [{ providerId: "google", accountId: "dev-staff" }],
roles: [
{
Expand All @@ -103,7 +103,7 @@ export const DEV_PERSONAS: DevPersona[] = [
id: "dev-student",
name: "Stella Student",
email: "student@polinetwork.test",
description: "Verified Polimi student with Telegram linked, no admin access",
description: "Verified Polimi student with Telegram linked",
accounts: [
{ providerId: "google", accountId: "dev-student" },
{
Expand All @@ -119,7 +119,7 @@ export const DEV_PERSONAS: DevPersona[] = [
id: "dev-member",
name: "Nico Newcomer",
email: "member@polinetwork.test",
description: "Just signed up with Google: no statuses, no roles",
description: "Blank login with Google",
accounts: [{ providerId: "google", accountId: "dev-member" }],
},
];
Expand All @@ -135,16 +135,16 @@ export function listPersonas(): DevPersonaSummary[] {
description,
...(id === DEV_ADMIN_ID && !env.IDP_ADMIN_USER_IDS.includes(DEV_ADMIN_ID)
? {
warning: `Add ${DEV_ADMIN_ID} to IDP_ADMIN_USER_IDS in .env.local to make this Master Admin.`,
}
warning: `Add ${DEV_ADMIN_ID} to IDP_ADMIN_USER_IDS in .env.local to make this Master Admin.`,
}
: {}),
}));
}

type Writer = Parameters<Parameters<typeof db.transaction>[0]>[0];

/** Thrown instead of silently moving an identity between people. */
export class DevFixtureConflict extends Error {}
export class DevFixtureConflict extends Error { }

/** Finds the fixture role by its stable ID, so a key renamed in the role editor still matches;
* then by key, so a role created by hand under that key is reused rather than collided with. */
Expand Down
Loading
Loading