Rebrand product as Manyangles and add Authentik
This commit is contained in:
@@ -3,6 +3,12 @@ NEXT_PUBLIC_APP_URL=http://localhost:3000
|
|||||||
BETTER_AUTH_SECRET=album-development-secret-change-me
|
BETTER_AUTH_SECRET=album-development-secret-change-me
|
||||||
BETTER_AUTH_URL=http://localhost:3000
|
BETTER_AUTH_URL=http://localhost:3000
|
||||||
|
|
||||||
|
# Optional Authentik OpenID Connect provider. Use the issuer URL shown by
|
||||||
|
# Authentik, for example: https://auth.example.com/application/o/vellum/
|
||||||
|
AUTHENTIK_ISSUER=
|
||||||
|
AUTHENTIK_CLIENT_ID=
|
||||||
|
AUTHENTIK_CLIENT_SECRET=
|
||||||
|
|
||||||
EMAIL_PROVIDER=mailpit
|
EMAIL_PROVIDER=mailpit
|
||||||
EMAIL_FROM=Album <photos@album.test>
|
EMAIL_FROM=Album <photos@album.test>
|
||||||
SMTP_HOST=127.0.0.1
|
SMTP_HOST=127.0.0.1
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Vellum repository guidance
|
# Manyangles repository guidance
|
||||||
|
|
||||||
- Use Bun exclusively for dependency installation and project scripts. Do not
|
- Use Bun exclusively for dependency installation and project scripts. Do not
|
||||||
introduce npm, pnpm, or Yarn lockfiles or commands.
|
introduce npm, pnpm, or Yarn lockfiles or commands.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Vellum
|
# Manyangles
|
||||||
|
|
||||||
Event photo collection for a wedding day — and for any gathering that needs
|
Event photo collection for a wedding day — and for any gathering that needs
|
||||||
the same guest-upload, host-moderate, public-gallery loop.
|
the same guest-upload, host-moderate, public-gallery loop.
|
||||||
@@ -46,3 +46,32 @@ Example accounts (password `host`, admin password `admin`):
|
|||||||
- `admin@example.com` — platform super-admin
|
- `admin@example.com` — platform super-admin
|
||||||
- `host@example.com` / `partner@example.com` — event owners
|
- `host@example.com` / `partner@example.com` — event owners
|
||||||
- `manager@example.com` — event manager
|
- `manager@example.com` — event manager
|
||||||
|
|
||||||
|
## Authentik sign-in
|
||||||
|
|
||||||
|
Manyangles can use an Authentik OAuth2/OpenID Connect provider alongside email and
|
||||||
|
password authentication. In Authentik, create a confidential OAuth2/OpenID
|
||||||
|
provider and application with the `openid`, `profile`, and `email` scopes.
|
||||||
|
|
||||||
|
Add a strict redirect URI for each Manyangles deployment:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://photos.example.com/api/auth/oauth2/callback/authentik
|
||||||
|
```
|
||||||
|
|
||||||
|
For local development, use
|
||||||
|
`http://localhost:3000/api/auth/oauth2/callback/authentik`.
|
||||||
|
|
||||||
|
Set all three variables to enable the Authentik button:
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
AUTHENTIK_ISSUER=https://auth.example.com/application/o/manyangles/
|
||||||
|
AUTHENTIK_CLIENT_ID=your-client-id
|
||||||
|
AUTHENTIK_CLIENT_SECRET=your-client-secret
|
||||||
|
```
|
||||||
|
|
||||||
|
`AUTHENTIK_ISSUER` is the application issuer URL, not the Authentik root URL.
|
||||||
|
Manyangles discovers the authorization, token, user-info, and signing-key endpoints
|
||||||
|
from `<issuer>/.well-known/openid-configuration`. If none of these variables are
|
||||||
|
set, Authentik support stays disabled. A partial configuration fails at startup
|
||||||
|
instead of silently hiding a broken provider.
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 19 KiB |
@@ -71,7 +71,7 @@ export function CreateEventDialog() {
|
|||||||
required
|
required
|
||||||
value={title}
|
value={title}
|
||||||
onChange={(event) => setTitle(event.target.value)}
|
onChange={(event) => setTitle(event.target.value)}
|
||||||
placeholder="Maya and Jonah"
|
placeholder="Summer block party"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field>
|
<Field>
|
||||||
@@ -88,8 +88,15 @@ export function CreateEventDialog() {
|
|||||||
<Input
|
<Input
|
||||||
id="invite-code"
|
id="invite-code"
|
||||||
value={inviteCode}
|
value={inviteCode}
|
||||||
onChange={(event) => setInviteCode(event.target.value)}
|
onChange={(event) =>
|
||||||
placeholder="VELLUM-…"
|
setInviteCode(event.target.value.toUpperCase().replace(/[^A-Z0-9]/g, ""))
|
||||||
|
}
|
||||||
|
placeholder="AB12"
|
||||||
|
minLength={4}
|
||||||
|
maxLength={4}
|
||||||
|
pattern="[A-Za-z0-9]{4}"
|
||||||
|
autoCapitalize="characters"
|
||||||
|
autoCorrect="off"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
</FieldGroup>
|
</FieldGroup>
|
||||||
|
|||||||
@@ -251,7 +251,7 @@ export function GuestUpload({
|
|||||||
autoComplete="name"
|
autoComplete="name"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(event) => setName(event.target.value)}
|
onChange={(event) => setName(event.target.value)}
|
||||||
placeholder="Maya…"
|
placeholder="Your name"
|
||||||
maxLength={80}
|
maxLength={80}
|
||||||
className="tap-target"
|
className="tap-target"
|
||||||
/>
|
/>
|
||||||
|
|||||||
+134
-75
@@ -19,9 +19,9 @@
|
|||||||
--color-border: var(--border);
|
--color-border: var(--border);
|
||||||
--color-input: var(--input);
|
--color-input: var(--input);
|
||||||
--color-ring: var(--ring);
|
--color-ring: var(--ring);
|
||||||
--font-sans: var(--font-source-sans), ui-sans-serif, system-ui, sans-serif;
|
--font-sans: var(--font-inter), ui-sans-serif, system-ui, sans-serif;
|
||||||
--font-heading: var(--font-source-sans), ui-sans-serif, system-ui, sans-serif;
|
--font-heading: var(--font-playfair), ui-serif, Georgia, serif;
|
||||||
--font-display: var(--font-cormorant), ui-serif, Georgia, serif;
|
--font-display: var(--font-playfair), ui-serif, Georgia, serif;
|
||||||
--color-sidebar-ring: var(--sidebar-ring);
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
--color-sidebar-border: var(--sidebar-border);
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
@@ -50,73 +50,73 @@
|
|||||||
|
|
||||||
:root {
|
:root {
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
--background: oklch(0.965 0.016 82);
|
--background: #f4f1ea;
|
||||||
--foreground: oklch(0.26 0.03 48);
|
--foreground: #171716;
|
||||||
--card: oklch(0.99 0.008 85);
|
--card: #fffefa;
|
||||||
--card-foreground: oklch(0.26 0.03 48);
|
--card-foreground: #171716;
|
||||||
--popover: oklch(0.995 0.006 85);
|
--popover: #fffefa;
|
||||||
--popover-foreground: oklch(0.26 0.03 48);
|
--popover-foreground: #171716;
|
||||||
--primary: oklch(0.46 0.09 38);
|
--primary: #3156c8;
|
||||||
--primary-foreground: oklch(0.99 0.01 85);
|
--primary-foreground: #ffffff;
|
||||||
--secondary: oklch(0.94 0.02 80);
|
--secondary: #e7e4dc;
|
||||||
--secondary-foreground: oklch(0.3 0.04 48);
|
--secondary-foreground: #252526;
|
||||||
--muted: oklch(0.94 0.018 82);
|
--muted: #e9e5dc;
|
||||||
--muted-foreground: oklch(0.48 0.03 55);
|
--muted-foreground: #5e5a52;
|
||||||
--accent: oklch(0.93 0.03 70);
|
--accent: #e4e9f8;
|
||||||
--accent-foreground: oklch(0.3 0.04 48);
|
--accent-foreground: #203b90;
|
||||||
--destructive: oklch(0.55 0.18 28);
|
--destructive: oklch(0.58 0.22 27);
|
||||||
--border: oklch(0.88 0.025 78);
|
--border: #c9c3b7;
|
||||||
--input: oklch(0.86 0.025 78);
|
--input: #c7c1b6;
|
||||||
--ring: oklch(0.46 0.09 38);
|
--ring: #3156c8;
|
||||||
--chart-1: oklch(0.72 0.08 55);
|
--chart-1: #3156c8;
|
||||||
--chart-2: oklch(0.55 0.08 40);
|
--chart-2: oklch(0.64 0.12 210);
|
||||||
--chart-3: oklch(0.45 0.06 70);
|
--chart-3: oklch(0.7 0.13 145);
|
||||||
--chart-4: oklch(0.38 0.04 50);
|
--chart-4: oklch(0.72 0.13 75);
|
||||||
--chart-5: oklch(0.3 0.03 45);
|
--chart-5: oklch(0.56 0.12 300);
|
||||||
--radius: 0.85rem;
|
--radius: 0.75rem;
|
||||||
--sidebar: oklch(0.97 0.012 82);
|
--sidebar: #fffefa;
|
||||||
--sidebar-foreground: oklch(0.26 0.03 48);
|
--sidebar-foreground: #171716;
|
||||||
--sidebar-primary: oklch(0.46 0.09 38);
|
--sidebar-primary: #3156c8;
|
||||||
--sidebar-primary-foreground: oklch(0.99 0.01 85);
|
--sidebar-primary-foreground: #ffffff;
|
||||||
--sidebar-accent: oklch(0.93 0.03 70);
|
--sidebar-accent: #e4e9f8;
|
||||||
--sidebar-accent-foreground: oklch(0.3 0.04 48);
|
--sidebar-accent-foreground: #203b90;
|
||||||
--sidebar-border: oklch(0.88 0.025 78);
|
--sidebar-border: #c9c3b7;
|
||||||
--sidebar-ring: oklch(0.46 0.09 38);
|
--sidebar-ring: #3156c8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
--background: oklch(0.2 0.02 50);
|
--background: #0d1018;
|
||||||
--foreground: oklch(0.96 0.015 85);
|
--foreground: #f4f1ea;
|
||||||
--card: oklch(0.25 0.022 52);
|
--card: #171b26;
|
||||||
--card-foreground: oklch(0.96 0.015 85);
|
--card-foreground: #f4f1ea;
|
||||||
--popover: oklch(0.25 0.022 52);
|
--popover: #1b202d;
|
||||||
--popover-foreground: oklch(0.96 0.015 85);
|
--popover-foreground: #f4f1ea;
|
||||||
--primary: oklch(0.78 0.07 65);
|
--primary: #7f9cff;
|
||||||
--primary-foreground: oklch(0.22 0.03 50);
|
--primary-foreground: #0c1634;
|
||||||
--secondary: oklch(0.3 0.025 55);
|
--secondary: #262b37;
|
||||||
--secondary-foreground: oklch(0.96 0.015 85);
|
--secondary-foreground: #f4f1ea;
|
||||||
--muted: oklch(0.3 0.022 52);
|
--muted: #242936;
|
||||||
--muted-foreground: oklch(0.76 0.03 75);
|
--muted-foreground: #bbb8b1;
|
||||||
--accent: oklch(0.32 0.03 58);
|
--accent: #202b50;
|
||||||
--accent-foreground: oklch(0.96 0.015 85);
|
--accent-foreground: #b9c7ff;
|
||||||
--destructive: oklch(0.7 0.14 25);
|
--destructive: oklch(0.7 0.18 25);
|
||||||
--border: oklch(0.96 0.02 85 / 12%);
|
--border: #3b4150;
|
||||||
--input: oklch(0.96 0.02 85 / 14%);
|
--input: #4a5161;
|
||||||
--ring: oklch(0.78 0.07 65);
|
--ring: #7f9cff;
|
||||||
--chart-1: oklch(0.78 0.07 65);
|
--chart-1: #7f9cff;
|
||||||
--chart-2: oklch(0.65 0.06 45);
|
--chart-2: oklch(0.7 0.12 210);
|
||||||
--chart-3: oklch(0.55 0.05 75);
|
--chart-3: oklch(0.72 0.13 145);
|
||||||
--chart-4: oklch(0.45 0.04 55);
|
--chart-4: oklch(0.75 0.13 75);
|
||||||
--chart-5: oklch(0.38 0.03 50);
|
--chart-5: oklch(0.68 0.12 300);
|
||||||
--sidebar: oklch(0.23 0.02 50);
|
--sidebar: #171b26;
|
||||||
--sidebar-foreground: oklch(0.96 0.015 85);
|
--sidebar-foreground: #f4f1ea;
|
||||||
--sidebar-primary: oklch(0.78 0.07 65);
|
--sidebar-primary: #7f9cff;
|
||||||
--sidebar-primary-foreground: oklch(0.22 0.03 50);
|
--sidebar-primary-foreground: #0c1634;
|
||||||
--sidebar-accent: oklch(0.3 0.025 55);
|
--sidebar-accent: #202b50;
|
||||||
--sidebar-accent-foreground: oklch(0.96 0.015 85);
|
--sidebar-accent-foreground: #b9c7ff;
|
||||||
--sidebar-border: oklch(0.96 0.02 85 / 12%);
|
--sidebar-border: #3b4150;
|
||||||
--sidebar-ring: oklch(0.78 0.07 65);
|
--sidebar-ring: #7f9cff;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes reveal-up {
|
@keyframes reveal-up {
|
||||||
@@ -166,6 +166,70 @@
|
|||||||
.tap-target {
|
.tap-target {
|
||||||
min-height: 2.75rem;
|
min-height: 2.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.album-preview {
|
||||||
|
border-color: color-mix(in oklab, var(--primary) 50%, var(--border));
|
||||||
|
box-shadow: 0 28px 80px color-mix(in oklab, var(--foreground) 17%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-preview > [data-slot="card-header"] {
|
||||||
|
color: var(--primary-foreground);
|
||||||
|
background: var(--primary);
|
||||||
|
border-color: color-mix(in oklab, var(--primary-foreground) 20%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-preview [data-slot="card-description"] {
|
||||||
|
color: color-mix(in oklab, var(--primary-foreground) 72%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-preview [data-slot="badge"] {
|
||||||
|
color: var(--primary-foreground);
|
||||||
|
background: color-mix(in oklab, var(--primary-foreground) 14%, transparent);
|
||||||
|
border-color: color-mix(in oklab, var(--primary-foreground) 24%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-panel {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-panel::before {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0 0 auto;
|
||||||
|
height: 3px;
|
||||||
|
content: "";
|
||||||
|
background: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.memory-photo {
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background-color: var(--muted);
|
||||||
|
box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--foreground) 8%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.memory-photo-one {
|
||||||
|
background: radial-gradient(circle at 50% 42%, oklch(0.91 0.04 62) 0 11%, transparent 12%), radial-gradient(ellipse at 48% 72%, oklch(0.48 0.08 35) 0 24%, transparent 25%), linear-gradient(145deg, oklch(0.82 0.1 76), oklch(0.58 0.09 38));
|
||||||
|
}
|
||||||
|
|
||||||
|
.memory-photo-two {
|
||||||
|
background: radial-gradient(circle at 30% 52%, oklch(0.95 0.03 75) 0 10%, transparent 11%), radial-gradient(circle at 67% 48%, oklch(0.93 0.035 62) 0 12%, transparent 13%), linear-gradient(150deg, oklch(0.57 0.1 230), oklch(0.83 0.08 205));
|
||||||
|
}
|
||||||
|
|
||||||
|
.memory-photo-three {
|
||||||
|
background: radial-gradient(circle at 50% 38%, oklch(0.9 0.045 55) 0 13%, transparent 14%), linear-gradient(25deg, transparent 42%, oklch(0.32 0.05 45) 43% 60%, transparent 61%), linear-gradient(155deg, oklch(0.8 0.12 135), oklch(0.48 0.09 150));
|
||||||
|
}
|
||||||
|
|
||||||
|
.memory-photo-four {
|
||||||
|
background: radial-gradient(circle at 50% 52%, oklch(0.95 0.025 62) 0 11%, transparent 12%), radial-gradient(ellipse at 52% 75%, oklch(0.36 0.055 265) 0 27%, transparent 28%), linear-gradient(165deg, oklch(0.73 0.11 35), oklch(0.37 0.07 300));
|
||||||
|
}
|
||||||
|
|
||||||
|
.memory-photo-five {
|
||||||
|
background: radial-gradient(circle at 70% 34%, oklch(0.91 0.09 85) 0 8%, transparent 9%), linear-gradient(20deg, oklch(0.42 0.08 155) 0 42%, transparent 43%), linear-gradient(155deg, oklch(0.76 0.09 215), oklch(0.93 0.06 80));
|
||||||
|
}
|
||||||
|
|
||||||
|
.memory-photo-six {
|
||||||
|
background: radial-gradient(circle at 35% 45%, oklch(0.94 0.035 65) 0 11%, transparent 12%), radial-gradient(circle at 65% 47%, oklch(0.92 0.04 58) 0 10%, transparent 11%), linear-gradient(145deg, oklch(0.77 0.11 28), oklch(0.47 0.08 20));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
@@ -191,26 +255,21 @@
|
|||||||
body {
|
body {
|
||||||
@apply bg-background font-sans text-foreground antialiased;
|
@apply bg-background font-sans text-foreground antialiased;
|
||||||
background-image:
|
background-image:
|
||||||
radial-gradient(1200px 520px at 8% -12%, oklch(0.92 0.045 70 / 0.55), transparent 60%),
|
radial-gradient(circle at 82% 7%, color-mix(in oklab, var(--primary) 15%, transparent), transparent 30rem),
|
||||||
radial-gradient(900px 420px at 110% 8%, oklch(0.93 0.035 40 / 0.4), transparent 55%);
|
radial-gradient(circle at 4% 88%, color-mix(in oklab, var(--primary) 5%, transparent), transparent 24rem);
|
||||||
background-attachment: fixed;
|
background-attachment: fixed;
|
||||||
touch-action: manipulation;
|
touch-action: manipulation;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark body {
|
|
||||||
background-image:
|
|
||||||
radial-gradient(1000px 480px at 0% -10%, oklch(0.32 0.04 55 / 0.55), transparent 60%),
|
|
||||||
radial-gradient(800px 380px at 100% 0%, oklch(0.3 0.04 70 / 0.35), transparent 55%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.font-display {
|
.font-display {
|
||||||
font-family: var(--font-cormorant), ui-serif, Georgia, serif;
|
font-family: var(--font-playfair), ui-serif, Georgia, serif;
|
||||||
text-wrap: balance;
|
text-wrap: balance;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1,
|
h1,
|
||||||
h2,
|
h2,
|
||||||
h3 {
|
h3 {
|
||||||
|
font-family: var(--font-playfair), ui-serif, Georgia, serif;
|
||||||
text-wrap: balance;
|
text-wrap: balance;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||||
<rect width="32" height="32" rx="8" fill="#f4eee4"/>
|
<rect width="32" height="32" rx="8" fill="#3156c8"/>
|
||||||
<path
|
<path d="M14 5H9a4 4 0 0 0-4 4v5M18 5h5a4 4 0 0 1 4 4v5M27 18v5a4 4 0 0 1-4 4h-5M14 27H9a4 4 0 0 1-4-4v-5" fill="none" stroke="#fff" stroke-width="2.3" stroke-linecap="round"/>
|
||||||
fill="#6b4032"
|
<circle cx="21" cy="11" r="1.8" fill="#fff"/>
|
||||||
fill-rule="evenodd"
|
<path d="m9 22 4.7-5.8a1.6 1.6 0 0 1 2.4-.1l2.5 2.8 1.6-1.7a1.6 1.6 0 0 1 2.4 0l1.4 1.6" fill="none" stroke="#fff" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
d="M8.5 5h15A5.5 5.5 0 0 1 29 10.5v13A5.5 5.5 0 0 1 23.5 29h-15A5.5 5.5 0 0 1 3 23.5v-13A5.5 5.5 0 0 1 8.5 5Zm10.2 0 10.3 10.3V10.5A5.5 5.5 0 0 0 23.5 5h-4.8Z"
|
|
||||||
/>
|
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 343 B After Width: | Height: | Size: 535 B |
@@ -1,5 +1,5 @@
|
|||||||
import type { Metadata, Viewport } from "next";
|
import type { Metadata, Viewport } from "next";
|
||||||
import { Cormorant_Garamond, Source_Sans_3 } from "next/font/google";
|
import { Inter, Playfair_Display } from "next/font/google";
|
||||||
import { TRPCReactProvider } from "@/trpc/react";
|
import { TRPCReactProvider } from "@/trpc/react";
|
||||||
import { ThemeProvider } from "@/components/theme-provider";
|
import { ThemeProvider } from "@/components/theme-provider";
|
||||||
import { SiteHeader } from "@/components/site-header";
|
import { SiteHeader } from "@/components/site-header";
|
||||||
@@ -7,17 +7,17 @@ import { Toaster } from "@/components/ui/sonner";
|
|||||||
import { BRAND_NAME, BRAND_TITLE } from "@/lib/brand";
|
import { BRAND_NAME, BRAND_TITLE } from "@/lib/brand";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
const sourceSans = Source_Sans_3({
|
const inter = Inter({
|
||||||
variable: "--font-source-sans",
|
variable: "--font-inter",
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
display: "swap",
|
display: "swap",
|
||||||
});
|
});
|
||||||
|
|
||||||
const cormorant = Cormorant_Garamond({
|
const playfair = Playfair_Display({
|
||||||
variable: "--font-cormorant",
|
variable: "--font-playfair",
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
display: "swap",
|
display: "swap",
|
||||||
weight: ["500", "600", "700"],
|
weight: ["600", "700", "800"],
|
||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@@ -32,8 +32,8 @@ export const metadata: Metadata = {
|
|||||||
|
|
||||||
export const viewport: Viewport = {
|
export const viewport: Viewport = {
|
||||||
themeColor: [
|
themeColor: [
|
||||||
{ media: "(prefers-color-scheme: light)", color: "#f4eee4" },
|
{ media: "(prefers-color-scheme: light)", color: "#f4f1ea" },
|
||||||
{ media: "(prefers-color-scheme: dark)", color: "#2a231c" },
|
{ media: "(prefers-color-scheme: dark)", color: "#0d1018" },
|
||||||
],
|
],
|
||||||
viewportFit: "cover",
|
viewportFit: "cover",
|
||||||
};
|
};
|
||||||
@@ -44,7 +44,7 @@ export default function RootLayout({
|
|||||||
return (
|
return (
|
||||||
<html
|
<html
|
||||||
lang="en"
|
lang="en"
|
||||||
className={`${sourceSans.variable} ${cormorant.variable} font-sans`}
|
className={`${inter.variable} ${playfair.variable} font-sans`}
|
||||||
suppressHydrationWarning
|
suppressHydrationWarning
|
||||||
>
|
>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export default function OpenGraphImage() {
|
|||||||
(
|
(
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
background: "#f4eee4",
|
background: "#f4f1ea",
|
||||||
width: "100%",
|
width: "100%",
|
||||||
height: "100%",
|
height: "100%",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
@@ -24,20 +24,19 @@ export default function OpenGraphImage() {
|
|||||||
viewBox="0 0 32 32"
|
viewBox="0 0 32 32"
|
||||||
fill="none"
|
fill="none"
|
||||||
>
|
>
|
||||||
<path
|
<path d="M14 4H8a4 4 0 0 0-4 4v6M18 4h6a4 4 0 0 1 4 4v6M28 18v6a4 4 0 0 1-4 4h-6M14 28H8a4 4 0 0 1-4-4v-6" fill="none" stroke="#3156c8" strokeWidth="2.5" strokeLinecap="round" />
|
||||||
fill="#6b4032"
|
<circle cx="21" cy="11" r="2" fill="#3156c8" />
|
||||||
fillRule="evenodd"
|
<path d="m8.5 22 5-6.2a1.7 1.7 0 0 1 2.6-.1l2.6 2.9 1.7-1.8a1.7 1.7 0 0 1 2.5 0l1.6 1.8" fill="none" stroke="#3156c8" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
d="M8.5 5h15A5.5 5.5 0 0 1 29 10.5v13A5.5 5.5 0 0 1 23.5 29h-15A5.5 5.5 0 0 1 3 23.5v-13A5.5 5.5 0 0 1 8.5 5Zm10.2 0 10.3 10.3V10.5A5.5 5.5 0 0 0 23.5 5h-4.8Z"
|
|
||||||
/>
|
|
||||||
</svg>
|
</svg>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
marginTop: 36,
|
marginTop: 36,
|
||||||
fontSize: 92,
|
fontSize: 92,
|
||||||
lineHeight: 1,
|
lineHeight: 1,
|
||||||
color: "#3a2a22",
|
color: "#18181b",
|
||||||
fontFamily: "Georgia, serif",
|
fontFamily: "Georgia, serif",
|
||||||
letterSpacing: -1,
|
fontWeight: 600,
|
||||||
|
letterSpacing: -5,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{BRAND_NAME}
|
{BRAND_NAME}
|
||||||
@@ -46,9 +45,10 @@ export default function OpenGraphImage() {
|
|||||||
style={{
|
style={{
|
||||||
marginTop: 18,
|
marginTop: 18,
|
||||||
fontSize: 28,
|
fontSize: 28,
|
||||||
color: "#8b5a4a",
|
maxWidth: 760,
|
||||||
letterSpacing: 6,
|
color: "#71717a",
|
||||||
textTransform: "uppercase",
|
lineHeight: 1.25,
|
||||||
|
letterSpacing: -1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{BRAND_TAGLINE}
|
{BRAND_TAGLINE}
|
||||||
|
|||||||
+157
-52
@@ -1,7 +1,15 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { CameraIcon } from "lucide-react";
|
import {
|
||||||
|
ArrowRightIcon,
|
||||||
|
CheckCircle2Icon,
|
||||||
|
DownloadIcon,
|
||||||
|
ImagesIcon,
|
||||||
|
ShieldCheckIcon,
|
||||||
|
UploadIcon,
|
||||||
|
} from "lucide-react";
|
||||||
import { createServerCaller } from "@/trpc/server";
|
import { createServerCaller } from "@/trpc/server";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { BrandMark } from "@/components/brand-mark";
|
import { BrandMark } from "@/components/brand-mark";
|
||||||
import { formatEventDate } from "@/lib/utils";
|
import { formatEventDate } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
@@ -12,73 +20,170 @@ import {
|
|||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
|
|
||||||
|
const features = [
|
||||||
|
{
|
||||||
|
number: "01",
|
||||||
|
icon: UploadIcon,
|
||||||
|
title: "One link for everyone",
|
||||||
|
body: "Guests upload from any phone. No account, app, or shared camera roll required.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
number: "02",
|
||||||
|
icon: ShieldCheckIcon,
|
||||||
|
title: "You decide what’s public",
|
||||||
|
body: "Every photo lands in a private review queue before it reaches the shared gallery.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
number: "03",
|
||||||
|
icon: DownloadIcon,
|
||||||
|
title: "Originals stay original",
|
||||||
|
body: "Keep the full-quality files while Manyangles makes the gallery fast for everyone else.",
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function AlbumPreview() {
|
||||||
|
return (
|
||||||
|
<div className="reveal-2 relative mx-auto w-full max-w-xl pb-8 sm:pb-10">
|
||||||
|
<Card className="album-preview overflow-hidden rounded-2xl py-0">
|
||||||
|
<CardHeader className="flex min-h-18 grid-cols-none flex-row items-center justify-between gap-4 border-b px-5 py-4 sm:px-6">
|
||||||
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
|
<span className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-primary-foreground/15 text-primary-foreground ring-1 ring-primary-foreground/25">
|
||||||
|
<ImagesIcon aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<CardTitle className="truncate text-base font-semibold">Saturday album</CardTitle>
|
||||||
|
<CardDescription>Brooklyn · 84 photos</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Badge variant="secondary" className="gap-1.5">
|
||||||
|
<span className="size-1.5 rounded-full bg-current" aria-hidden="true" />
|
||||||
|
Live
|
||||||
|
</Badge>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-2.5 sm:p-3">
|
||||||
|
<div className="grid h-[20rem] grid-cols-3 grid-rows-3 gap-2 sm:h-[25rem] sm:gap-2.5" aria-hidden="true">
|
||||||
|
<div className="memory-photo memory-photo-one row-span-2" />
|
||||||
|
<div className="memory-photo memory-photo-two col-span-2" />
|
||||||
|
<div className="memory-photo memory-photo-three" />
|
||||||
|
<div className="memory-photo memory-photo-four row-span-2" />
|
||||||
|
<div className="memory-photo memory-photo-five" />
|
||||||
|
<div className="memory-photo memory-photo-six" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<div className="absolute right-3 bottom-0 left-3 flex items-center gap-3 rounded-xl border border-border/80 bg-card/90 p-3 shadow-xl backdrop-blur-xl sm:right-8 sm:left-auto sm:min-w-64 sm:p-4">
|
||||||
|
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary text-primary-foreground">
|
||||||
|
<CheckCircle2Icon aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block text-sm font-semibold">12 photos added</span>
|
||||||
|
<span className="block truncate text-xs text-muted-foreground">Original quality · ready to review</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default async function HomePage() {
|
export default async function HomePage() {
|
||||||
const caller = await createServerCaller();
|
const caller = await createServerCaller();
|
||||||
const listed = await caller.event.listed();
|
const [listed, viewer] = await Promise.all([
|
||||||
const viewer = await caller.viewer.me();
|
caller.event.listed(),
|
||||||
|
caller.viewer.me(),
|
||||||
|
]);
|
||||||
|
const demoEvent = listed[0];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="page-pad mx-auto flex min-h-[calc(100dvh-3.5rem)] w-full max-w-5xl flex-col gap-12 py-10 sm:min-h-[calc(100dvh-4rem)] sm:py-16">
|
<main>
|
||||||
<div className="reveal flex max-w-2xl flex-col gap-5">
|
<section className="page-pad mx-auto grid min-h-[calc(100svh-4rem)] w-full max-w-6xl items-center gap-12 py-14 sm:py-20 lg:grid-cols-[0.88fr_1.12fr] lg:gap-16 lg:py-24">
|
||||||
<p className="text-xs font-medium tracking-[0.22em] text-primary uppercase sm:text-sm">
|
<div className="reveal flex max-w-xl flex-col gap-6">
|
||||||
Photos from the day
|
<div className="flex items-center gap-3 text-xs font-bold tracking-[0.16em] text-primary uppercase">
|
||||||
</p>
|
<span className="h-px w-9 bg-primary" aria-hidden="true" />
|
||||||
<h1 className="font-display text-4xl leading-[1.1] sm:text-6xl">
|
Shared albums for real life
|
||||||
Keep the originals. Share the day.
|
</div>
|
||||||
</h1>
|
<h1 className="font-display text-[clamp(3.4rem,7vw,5.8rem)] leading-[0.92] font-semibold tracking-[-0.055em]">
|
||||||
<p className="max-w-xl text-base text-muted-foreground sm:text-lg">
|
Every angle.<br /><span className="text-primary">One shared album.</span>
|
||||||
Guests upload from a link. You choose what the public sees. Full-quality
|
</h1>
|
||||||
files stay yours.
|
<p className="max-w-lg text-lg leading-7 text-muted-foreground sm:text-xl sm:leading-8">
|
||||||
</p>
|
Give guests one link to add their photos. Review everything in one place, share the best, and keep every original.
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
|
</p>
|
||||||
{viewer.openSignup ? (
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
|
||||||
<Button asChild size="lg" className="tap-target w-full sm:w-auto">
|
{viewer.openSignup ? (
|
||||||
<Link href="/sign-up">
|
<Button asChild size="lg" className="tap-target w-full sm:w-auto">
|
||||||
<CameraIcon data-icon="inline-start" aria-hidden="true" />
|
<Link href="/sign-up">Create an event<ArrowRightIcon data-icon="inline-end" aria-hidden="true" /></Link>
|
||||||
Host an event
|
</Button>
|
||||||
</Link>
|
) : (
|
||||||
</Button>
|
<Button asChild size="lg" className="tap-target w-full sm:w-auto"><Link href="/sign-up">Use an invite</Link></Button>
|
||||||
) : (
|
)}
|
||||||
<Button asChild size="lg" className="tap-target w-full sm:w-auto">
|
{demoEvent ? (
|
||||||
<Link href="/sign-up">Have an invite?</Link>
|
<Button asChild variant="outline" size="lg" className="tap-target w-full sm:w-auto"><Link href={`/e/${demoEvent.slug}`}>Open a gallery</Link></Button>
|
||||||
</Button>
|
) : (
|
||||||
)}
|
<Button asChild variant="outline" size="lg" className="tap-target w-full sm:w-auto"><Link href="/sign-in">Sign in</Link></Button>
|
||||||
<Button asChild variant="outline" size="lg" className="tap-target w-full sm:w-auto">
|
)}
|
||||||
<Link href="/sign-in">Sign in</Link>
|
</div>
|
||||||
</Button>
|
<div className="flex flex-wrap gap-x-4 gap-y-2 border-t border-border/80 pt-4 text-[0.6875rem] font-bold tracking-[0.08em] text-muted-foreground uppercase">
|
||||||
|
<span>No app required</span><span>Original quality</span><span>Private by default</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<AlbumPreview />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="border-y border-border/80 bg-card/80 backdrop-blur-sm" aria-labelledby="how-it-works">
|
||||||
|
<div className="page-pad mx-auto w-full max-w-6xl py-14 sm:py-20">
|
||||||
|
<p className="text-xs font-bold tracking-[0.14em] text-primary uppercase">The whole handoff, handled</p>
|
||||||
|
<h2 id="how-it-works" className="mt-3 max-w-2xl text-4xl font-semibold tracking-[-0.035em] sm:text-5xl">
|
||||||
|
Built for the photos that never make it out of the group chat.
|
||||||
|
</h2>
|
||||||
|
<div className="mt-8 grid gap-3 md:grid-cols-3">
|
||||||
|
{features.map((feature) => {
|
||||||
|
const Icon = feature.icon;
|
||||||
|
return (
|
||||||
|
<Card key={feature.title} className="feature-panel overflow-hidden bg-background/60 shadow-none">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<span className="flex size-10 items-center justify-center rounded-lg bg-primary text-primary-foreground"><Icon aria-hidden="true" /></span>
|
||||||
|
<span className="font-mono text-xs font-bold tracking-widest text-muted-foreground">{feature.number}</span>
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-xl font-semibold">{feature.title}</CardTitle>
|
||||||
|
<CardDescription className="leading-6">{feature.body}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
{listed.length > 0 ? (
|
{listed.length > 0 ? (
|
||||||
<section className="reveal-2 flex flex-col gap-4">
|
<section className="page-pad mx-auto flex w-full max-w-6xl flex-col gap-6 py-14 sm:py-20">
|
||||||
<h2 className="text-2xl font-semibold tracking-tight sm:text-3xl">Open galleries</h2>
|
<div className="flex flex-col gap-1 sm:flex-row sm:items-end sm:justify-between">
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<div>
|
||||||
|
<p className="text-xs font-bold tracking-[0.14em] text-primary uppercase">Open now</p>
|
||||||
|
<h2 className="mt-2 text-4xl font-semibold tracking-[-0.035em]">Public galleries</h2>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">Join the day from wherever you are.</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{listed.map((event) => (
|
{listed.map((event) => (
|
||||||
<Link
|
<Link key={event.id} href={`/e/${event.slug}`} className="group block min-w-0 rounded-xl focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none">
|
||||||
key={event.id}
|
|
||||||
href={`/e/${event.slug}`}
|
|
||||||
className="group block min-w-0 rounded-xl focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none"
|
|
||||||
>
|
|
||||||
<Card className="h-full transition-transform duration-200 ease-out group-hover:-translate-y-0.5 group-hover:shadow-md motion-reduce:transition-none motion-reduce:group-hover:translate-y-0">
|
<Card className="h-full transition-transform duration-200 ease-out group-hover:-translate-y-0.5 group-hover:shadow-md motion-reduce:transition-none motion-reduce:group-hover:translate-y-0">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="mb-2 flex size-10 items-center justify-center rounded-full bg-accent text-primary">
|
<div className="mb-2 flex size-10 items-center justify-center rounded-lg bg-primary text-primary-foreground"><BrandMark className="size-5" /></div>
|
||||||
<BrandMark className="size-5" />
|
<CardTitle className="truncate text-xl font-semibold tracking-tight">{event.title}</CardTitle>
|
||||||
</div>
|
<CardDescription>{formatEventDate(event.startsAt) ?? "Open gallery"}</CardDescription>
|
||||||
<CardTitle className="truncate text-xl font-semibold tracking-tight">
|
|
||||||
{event.title}
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
{formatEventDate(event.startsAt) ?? "Open gallery"}
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="flex items-center gap-1 text-sm font-medium text-primary">View gallery<ArrowRightIcon aria-hidden="true" /></CardContent>
|
||||||
<span className="text-sm text-primary">View gallery</span>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
</Card>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
<footer className="border-t border-border/80 bg-foreground text-background">
|
||||||
|
<div className="page-pad mx-auto flex w-full max-w-6xl items-center justify-between gap-4 py-7 text-xs text-background/65">
|
||||||
|
<span className="font-heading text-lg font-semibold text-background">Manyangles</span><span>Keep the moment, not the compression.</span>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { auth } from "@/server/auth";
|
import { auth, authentikEnabled } from "@/server/auth";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
@@ -14,9 +14,9 @@ import { SignInForm } from "./sign-in-form";
|
|||||||
export default async function SignInPage({
|
export default async function SignInPage({
|
||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
searchParams: Promise<{ callbackURL?: string }>;
|
searchParams: Promise<{ callbackURL?: string; error?: string }>;
|
||||||
}) {
|
}) {
|
||||||
const { callbackURL } = await searchParams;
|
const { callbackURL, error } = await searchParams;
|
||||||
const safeCallback =
|
const safeCallback =
|
||||||
callbackURL?.startsWith("/") && !callbackURL.startsWith("//")
|
callbackURL?.startsWith("/") && !callbackURL.startsWith("//")
|
||||||
? callbackURL
|
? callbackURL
|
||||||
@@ -29,10 +29,14 @@ export default async function SignInPage({
|
|||||||
<Card className="reveal w-full">
|
<Card className="reveal w-full">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-2xl font-semibold tracking-tight">Sign in</CardTitle>
|
<CardTitle className="text-2xl font-semibold tracking-tight">Sign in</CardTitle>
|
||||||
<CardDescription>Access your Vellum host account.</CardDescription>
|
<CardDescription>Access your Manyangles host account.</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-col gap-5">
|
<CardContent className="flex flex-col gap-5">
|
||||||
<SignInForm callbackURL={safeCallback} />
|
<SignInForm
|
||||||
|
callbackURL={safeCallback}
|
||||||
|
authentikEnabled={authentikEnabled}
|
||||||
|
authentikError={Boolean(error)}
|
||||||
|
/>
|
||||||
<p className="text-center text-sm text-muted-foreground">
|
<p className="text-center text-sm text-muted-foreground">
|
||||||
Need an account?{" "}
|
Need an account?{" "}
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -3,23 +3,55 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { LogInIcon } from "lucide-react";
|
import { LogInIcon, ShieldCheckIcon } from "lucide-react";
|
||||||
import { authClient } from "@/lib/auth-client";
|
import { authClient } from "@/lib/auth-client";
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
|
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
|
||||||
const SIGN_IN_ERROR_MESSAGE =
|
const SIGN_IN_ERROR_MESSAGE =
|
||||||
"Unable to sign in. Check your email and password, then try again.";
|
"Unable to sign in. Check your email and password, then try again.";
|
||||||
|
|
||||||
export function SignInForm({ callbackURL }: { callbackURL: string }) {
|
export function SignInForm({
|
||||||
|
callbackURL,
|
||||||
|
authentikEnabled,
|
||||||
|
authentikError,
|
||||||
|
}: {
|
||||||
|
callbackURL: string;
|
||||||
|
authentikEnabled: boolean;
|
||||||
|
authentikError: boolean;
|
||||||
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(
|
||||||
|
authentikError ? "Unable to sign in with Authentik. Check your access and try again." : null,
|
||||||
|
);
|
||||||
|
const [authentikLoading, setAuthentikLoading] = useState(false);
|
||||||
|
|
||||||
|
async function signInWithAuthentik() {
|
||||||
|
setAuthentikLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const errorCallbackURL = `/sign-in?callbackURL=${encodeURIComponent(callbackURL)}`;
|
||||||
|
try {
|
||||||
|
const result = await authClient.signIn.oauth2({
|
||||||
|
providerId: "authentik",
|
||||||
|
callbackURL,
|
||||||
|
errorCallbackURL,
|
||||||
|
});
|
||||||
|
if (result.error) {
|
||||||
|
setError("Unable to start Authentik sign-in. Try again.");
|
||||||
|
setAuthentikLoading(false);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError("Unable to start Authentik sign-in. Try again.");
|
||||||
|
setAuthentikLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function submit(event: React.FormEvent<HTMLFormElement>) {
|
async function submit(event: React.FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -45,7 +77,32 @@ export function SignInForm({ callbackURL }: { callbackURL: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form className="flex flex-col gap-5" onSubmit={submit}>
|
<div className="flex flex-col gap-5">
|
||||||
|
{authentikEnabled ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="lg"
|
||||||
|
className="tap-target w-full"
|
||||||
|
disabled={authentikLoading || loading}
|
||||||
|
onClick={() => void signInWithAuthentik()}
|
||||||
|
>
|
||||||
|
{authentikLoading ? (
|
||||||
|
<Spinner data-icon="inline-start" />
|
||||||
|
) : (
|
||||||
|
<ShieldCheckIcon data-icon="inline-start" />
|
||||||
|
)}
|
||||||
|
Continue with Authentik
|
||||||
|
</Button>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Separator className="flex-1" />
|
||||||
|
<span className="text-xs text-muted-foreground">or use email</span>
|
||||||
|
<Separator className="flex-1" />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
<form className="flex flex-col gap-5" onSubmit={submit}>
|
||||||
<FieldGroup>
|
<FieldGroup>
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel htmlFor="email">Email address</FieldLabel>
|
<FieldLabel htmlFor="email">Email address</FieldLabel>
|
||||||
@@ -95,6 +152,7 @@ export function SignInForm({ callbackURL }: { callbackURL: string }) {
|
|||||||
)}
|
)}
|
||||||
Sign in
|
Sign in
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,8 +103,15 @@ export function SignUpForm({
|
|||||||
<Input
|
<Input
|
||||||
id="invite"
|
id="invite"
|
||||||
value={inviteCode}
|
value={inviteCode}
|
||||||
onChange={(event) => setInviteCode(event.target.value)}
|
onChange={(event) =>
|
||||||
placeholder="VELLUM-…"
|
setInviteCode(event.target.value.toUpperCase().replace(/[^A-Z0-9]/g, ""))
|
||||||
|
}
|
||||||
|
placeholder="AB12"
|
||||||
|
minLength={4}
|
||||||
|
maxLength={4}
|
||||||
|
pattern="[A-Za-z0-9]{4}"
|
||||||
|
autoCapitalize="characters"
|
||||||
|
autoCorrect="off"
|
||||||
className="tap-target"
|
className="tap-target"
|
||||||
required={requireInvite}
|
required={requireInvite}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -10,9 +10,18 @@ export function BrandMark({ className }: { className?: string }) {
|
|||||||
className={cn("size-7 shrink-0", className)}
|
className={cn("size-7 shrink-0", className)}
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
fill="currentColor"
|
d="M14 4H8a4 4 0 0 0-4 4v6M18 4h6a4 4 0 0 1 4 4v6M28 18v6a4 4 0 0 1-4 4h-6M14 28H8a4 4 0 0 1-4-4v-6"
|
||||||
fillRule="evenodd"
|
stroke="currentColor"
|
||||||
d="M8.5 5h15A5.5 5.5 0 0 1 29 10.5v13A5.5 5.5 0 0 1 23.5 29h-15A5.5 5.5 0 0 1 3 23.5v-13A5.5 5.5 0 0 1 8.5 5Zm10.2 0 10.3 10.3V10.5A5.5 5.5 0 0 0 23.5 5h-4.8Z"
|
strokeWidth="2.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
<circle cx="21" cy="11" r="2" fill="currentColor" />
|
||||||
|
<path
|
||||||
|
d="m8.5 22 5-6.2a1.7 1.7 0 0 1 2.6-.1l2.6 2.9 1.7-1.8a1.7 1.7 0 0 1 2.5 0l1.6 1.8"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
@@ -31,7 +40,7 @@ export function BrandLockup({
|
|||||||
<span className={cn("inline-flex items-center gap-2", className)}>
|
<span className={cn("inline-flex items-center gap-2", className)}>
|
||||||
<BrandMark className={cn("text-primary", markClassName)} />
|
<BrandMark className={cn("text-primary", markClassName)} />
|
||||||
<span
|
<span
|
||||||
className={cn("font-semibold tracking-tight", wordmarkClassName)}
|
className={cn("font-heading font-semibold tracking-[-0.035em]", wordmarkClassName)}
|
||||||
translate="no"
|
translate="no"
|
||||||
>
|
>
|
||||||
{BRAND_NAME}
|
{BRAND_NAME}
|
||||||
|
|||||||
@@ -30,14 +30,14 @@ export function SiteHeaderBar({
|
|||||||
signedIn: boolean;
|
signedIn: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-40 border-b border-border/70 bg-background/80 pt-[env(safe-area-inset-top)] backdrop-blur-md">
|
<header className="sticky top-0 z-40 border-b border-border/80 bg-card/82 pt-[env(safe-area-inset-top)] backdrop-blur-xl">
|
||||||
<div className="page-pad mx-auto flex h-14 w-full max-w-6xl items-center justify-between gap-3 sm:h-16">
|
<div className="page-pad mx-auto flex h-14 w-full max-w-6xl items-center justify-between gap-3 sm:h-16">
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
href="/"
|
||||||
className="text-xl sm:text-2xl"
|
className="text-xl"
|
||||||
aria-label={`${BRAND_NAME} home`}
|
aria-label={`${BRAND_NAME} home`}
|
||||||
>
|
>
|
||||||
<BrandLockup markClassName="size-6 sm:size-7" />
|
<BrandLockup markClassName="size-6" />
|
||||||
</Link>
|
</Link>
|
||||||
<nav className="hidden items-center gap-1 sm:flex">
|
<nav className="hidden items-center gap-1 sm:flex">
|
||||||
{links.map((link) => (
|
{links.map((link) => (
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ import { cn } from "cn"
|
|||||||
import { Slot } from "radix-ui"
|
import { Slot } from "radix-ui"
|
||||||
|
|
||||||
const buttonVariants = cva(
|
const buttonVariants = cva(
|
||||||
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-semibold whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/35 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: "bg-primary text-primary-foreground hover:bg-primary/80",
|
default: "bg-primary text-primary-foreground shadow-sm hover:bg-primary/90 hover:shadow-md",
|
||||||
outline:
|
outline:
|
||||||
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||||
secondary:
|
secondary:
|
||||||
@@ -21,16 +21,16 @@ const buttonVariants = cva(
|
|||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default:
|
default:
|
||||||
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
"h-10 gap-2 px-3.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
|
||||||
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||||
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
sm: "h-9 gap-1.5 rounded-[min(var(--radius-md),12px)] px-3 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-2.5 has-data-[icon=inline-start]:pl-2.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||||
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
lg: "h-12 gap-2 px-5 text-base has-data-[icon=inline-end]:pr-4 has-data-[icon=inline-start]:pl-4",
|
||||||
icon: "size-8",
|
icon: "size-10",
|
||||||
"icon-xs":
|
"icon-xs":
|
||||||
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||||
"icon-sm":
|
"icon-sm":
|
||||||
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
"size-9 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
||||||
"icon-lg": "size-9",
|
"icon-lg": "size-11",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ function Card({
|
|||||||
data-slot="card"
|
data-slot="card"
|
||||||
data-size={size}
|
data-size={size}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-xl bg-card py-(--card-spacing) text-sm text-card-foreground ring-1 ring-foreground/10 [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
|
"group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-xl border border-border/85 bg-card/92 py-(--card-spacing) text-sm text-card-foreground shadow-sm backdrop-blur-xl [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
|||||||
type={type}
|
type={type}
|
||||||
data-slot="input"
|
data-slot="input"
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
"h-10 w-full min-w-0 rounded-lg border border-input bg-card/55 px-3 py-1 text-base transition-colors outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ function SelectTrigger({
|
|||||||
data-slot="select-trigger"
|
data-slot="select-trigger"
|
||||||
data-size={size}
|
data-size={size}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-card/55 py-2 pr-3 pl-3 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-10 data-[size=sm]:h-9 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
|||||||
<textarea
|
<textarea
|
||||||
data-slot="textarea"
|
data-slot="textarea"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
"flex field-sizing-content min-h-24 w-full rounded-lg border border-input bg-card/55 px-3 py-2.5 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
import { existsSync } from "node:fs";
|
|
||||||
import { resolve } from "node:path";
|
|
||||||
import { config } from "dotenv";
|
|
||||||
|
|
||||||
export async function register() {
|
|
||||||
for (const path of [
|
|
||||||
resolve(process.cwd(), ".env"),
|
|
||||||
resolve(process.cwd(), "../../.env"),
|
|
||||||
]) {
|
|
||||||
if (existsSync(path)) {
|
|
||||||
config({ path, override: false });
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
import { createAuthClient } from "better-auth/react";
|
import { createAuthClient } from "better-auth/react";
|
||||||
|
import { genericOAuthClient } from "better-auth/client/plugins";
|
||||||
|
|
||||||
export const authClient = createAuthClient();
|
export const authClient = createAuthClient({
|
||||||
|
plugins: [genericOAuthClient()],
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
export const BRAND_NAME = "Vellum";
|
export const BRAND_NAME = "Manyangles";
|
||||||
export const BRAND_TAGLINE = "Photos from the day";
|
export const BRAND_TAGLINE = "Every angle. One shared album.";
|
||||||
export const BRAND_TITLE = "Vellum — photos from the day";
|
export const BRAND_TITLE = "Manyangles — shared albums for real life";
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { slugify } from "./slug";
|
|||||||
|
|
||||||
describe("slugify", () => {
|
describe("slugify", () => {
|
||||||
test("lowercases and hyphenates titles", () => {
|
test("lowercases and hyphenates titles", () => {
|
||||||
expect(slugify("Maya & Jonah Wedding")).toBe("maya-jonah-wedding");
|
expect(slugify("Summer Block Party")).toBe("summer-block-party");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("falls back when the title has no letters", () => {
|
test("falls back when the title has no letters", () => {
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ import { getPlatformRole } from "@/server/roles";
|
|||||||
import { resolveGroupQuota } from "@/server/entitlements";
|
import { resolveGroupQuota } from "@/server/entitlements";
|
||||||
import { countGroupOwners } from "@/server/membership";
|
import { countGroupOwners } from "@/server/membership";
|
||||||
import { writeAudit } from "@/server/audit";
|
import { writeAudit } from "@/server/audit";
|
||||||
import { hashToken, newInviteCode, newToken } from "@/server/tokens";
|
import { hashToken, newToken } from "@/server/tokens";
|
||||||
|
import { createInviteCode } from "@/server/invites";
|
||||||
import { sendStaffInviteEmail } from "@album/email";
|
import { sendStaffInviteEmail } from "@album/email";
|
||||||
import { publicAppOrigin } from "@/server/public-app-url";
|
import { publicAppOrigin } from "@/server/public-app-url";
|
||||||
import { GROUP_COOKIE, serializeCookie } from "@/server/cookies";
|
import { GROUP_COOKIE, serializeCookie } from "@/server/cookies";
|
||||||
@@ -265,10 +266,7 @@ export const groupRouter = createTRPCRouter({
|
|||||||
platformRole,
|
platformRole,
|
||||||
);
|
);
|
||||||
requireGroupPermission(access.permissions, GROUP_PERMISSIONS.PEOPLE_MANAGE);
|
requireGroupPermission(access.permissions, GROUP_PERMISSIONS.PEOPLE_MANAGE);
|
||||||
const code = newInviteCode();
|
const code = await createInviteCode({
|
||||||
await getDb().insert(invites).values({
|
|
||||||
kind: "code",
|
|
||||||
tokenHash: hashToken(code),
|
|
||||||
reusable: input.reusable ?? false,
|
reusable: input.reusable ?? false,
|
||||||
maxUses: input.maxUses ?? 1,
|
maxUses: input.maxUses ?? 1,
|
||||||
groupId: input.groupId,
|
groupId: input.groupId,
|
||||||
|
|||||||
@@ -3,9 +3,22 @@ import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
|
|||||||
import { findInviteByToken, redeemInviteForUser } from "@/server/invites";
|
import { findInviteByToken, redeemInviteForUser } from "@/server/invites";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { getDeploymentSettings } from "@/server/settings";
|
import { getDeploymentSettings } from "@/server/settings";
|
||||||
|
import { consumeRateLimit } from "@/server/rate-limit";
|
||||||
|
|
||||||
export const invitesRouter = createTRPCRouter({
|
export const invitesRouter = createTRPCRouter({
|
||||||
preview: publicProcedure.input(redeemInviteInputSchema).query(async ({ input }) => {
|
preview: publicProcedure.input(redeemInviteInputSchema).query(async ({ ctx, input }) => {
|
||||||
|
const limit = await consumeRateLimit({
|
||||||
|
namespace: "invite-preview",
|
||||||
|
identifier: ctx.clientIdentifier,
|
||||||
|
limit: 12,
|
||||||
|
windowMs: 5 * 60 * 1000,
|
||||||
|
});
|
||||||
|
if (!limit.allowed) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "TOO_MANY_REQUESTS",
|
||||||
|
message: "Too many invite attempts. Try again shortly.",
|
||||||
|
});
|
||||||
|
}
|
||||||
const invite = await findInviteByToken(input.token);
|
const invite = await findInviteByToken(input.token);
|
||||||
if (!invite) {
|
if (!invite) {
|
||||||
throw new TRPCError({ code: "NOT_FOUND", message: "Invite not found" });
|
throw new TRPCError({ code: "NOT_FOUND", message: "Invite not found" });
|
||||||
@@ -23,6 +36,18 @@ export const invitesRouter = createTRPCRouter({
|
|||||||
redeem: protectedProcedure
|
redeem: protectedProcedure
|
||||||
.input(redeemInviteInputSchema)
|
.input(redeemInviteInputSchema)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const limit = await consumeRateLimit({
|
||||||
|
namespace: "invite-redeem",
|
||||||
|
identifier: `${ctx.session.user.id}:${ctx.clientIdentifier}`,
|
||||||
|
limit: 12,
|
||||||
|
windowMs: 5 * 60 * 1000,
|
||||||
|
});
|
||||||
|
if (!limit.allowed) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "TOO_MANY_REQUESTS",
|
||||||
|
message: "Too many invite attempts. Try again shortly.",
|
||||||
|
});
|
||||||
|
}
|
||||||
return redeemInviteForUser({
|
return redeemInviteForUser({
|
||||||
token: input.token,
|
token: input.token,
|
||||||
userId: ctx.session.user.id,
|
userId: ctx.session.user.id,
|
||||||
|
|||||||
@@ -26,8 +26,7 @@ import { hasPlatformPermission } from "@/server/roles";
|
|||||||
import { getDeploymentSettings } from "@/server/settings";
|
import { getDeploymentSettings } from "@/server/settings";
|
||||||
import { grantEntitlement, resolveGroupQuota } from "@/server/entitlements";
|
import { grantEntitlement, resolveGroupQuota } from "@/server/entitlements";
|
||||||
import { writeAudit } from "@/server/audit";
|
import { writeAudit } from "@/server/audit";
|
||||||
import { hashToken, newInviteCode } from "@/server/tokens";
|
import { createInviteCode } from "@/server/invites";
|
||||||
import { invites } from "@album/database";
|
|
||||||
|
|
||||||
export const platformRouter = createTRPCRouter({
|
export const platformRouter = createTRPCRouter({
|
||||||
settings: platformPermissionProcedure(PLATFORM_PERMISSIONS.SETTINGS_MANAGE).query(
|
settings: platformPermissionProcedure(PLATFORM_PERMISSIONS.SETTINGS_MANAGE).query(
|
||||||
@@ -200,10 +199,7 @@ export const platformRouter = createTRPCRouter({
|
|||||||
createCode: platformPermissionProcedure(PLATFORM_PERMISSIONS.ENTITLEMENTS_MANAGE)
|
createCode: platformPermissionProcedure(PLATFORM_PERMISSIONS.ENTITLEMENTS_MANAGE)
|
||||||
.input(createInviteCodeInputSchema)
|
.input(createInviteCodeInputSchema)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const code = newInviteCode();
|
const code = await createInviteCode({
|
||||||
await getDb().insert(invites).values({
|
|
||||||
kind: "code",
|
|
||||||
tokenHash: hashToken(code),
|
|
||||||
reusable: input.reusable ?? true,
|
reusable: input.reusable ?? true,
|
||||||
maxUses: input.maxUses ?? 100,
|
maxUses: input.maxUses ?? 100,
|
||||||
groupId: input.groupId ?? null,
|
groupId: input.groupId ?? null,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { betterAuth } from "better-auth";
|
import { betterAuth } from "better-auth";
|
||||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||||
|
import { genericOAuth } from "better-auth/plugins";
|
||||||
import { getDb } from "@album/database";
|
import { getDb } from "@album/database";
|
||||||
import {
|
import {
|
||||||
sendAccountVerificationEmail,
|
sendAccountVerificationEmail,
|
||||||
@@ -11,8 +12,30 @@ const baseURL =
|
|||||||
process.env.NEXT_PUBLIC_APP_URL ??
|
process.env.NEXT_PUBLIC_APP_URL ??
|
||||||
"http://localhost:3000";
|
"http://localhost:3000";
|
||||||
|
|
||||||
|
const authentikIssuer = process.env.AUTHENTIK_ISSUER?.trim();
|
||||||
|
const authentikClientId = process.env.AUTHENTIK_CLIENT_ID?.trim();
|
||||||
|
const authentikClientSecret = process.env.AUTHENTIK_CLIENT_SECRET?.trim();
|
||||||
|
const authentikValues = [
|
||||||
|
authentikIssuer,
|
||||||
|
authentikClientId,
|
||||||
|
authentikClientSecret,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (authentikValues.some(Boolean) && !authentikValues.every(Boolean)) {
|
||||||
|
throw new Error(
|
||||||
|
"Authentik requires AUTHENTIK_ISSUER, AUTHENTIK_CLIENT_ID, and AUTHENTIK_CLIENT_SECRET",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const authentikEnabled = authentikValues.every(Boolean);
|
||||||
|
|
||||||
|
function authentikDiscoveryUrl(issuer: string) {
|
||||||
|
const base = issuer.endsWith("/") ? issuer : `${issuer}/`;
|
||||||
|
return new URL(".well-known/openid-configuration", base).toString();
|
||||||
|
}
|
||||||
|
|
||||||
export const auth = betterAuth({
|
export const auth = betterAuth({
|
||||||
appName: "Vellum",
|
appName: "Manyangles",
|
||||||
baseURL,
|
baseURL,
|
||||||
secret: process.env.BETTER_AUTH_SECRET,
|
secret: process.env.BETTER_AUTH_SECRET,
|
||||||
advanced: {
|
advanced: {
|
||||||
@@ -21,6 +44,12 @@ export const auth = betterAuth({
|
|||||||
database: drizzleAdapter(getDb(), {
|
database: drizzleAdapter(getDb(), {
|
||||||
provider: "pg",
|
provider: "pg",
|
||||||
}),
|
}),
|
||||||
|
account: {
|
||||||
|
accountLinking: {
|
||||||
|
enabled: true,
|
||||||
|
trustedProviders: authentikEnabled ? ["authentik"] : [],
|
||||||
|
},
|
||||||
|
},
|
||||||
user: {
|
user: {
|
||||||
changeEmail: {
|
changeEmail: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -59,4 +88,20 @@ export const auth = betterAuth({
|
|||||||
expiresIn: 60 * 60 * 24 * 7,
|
expiresIn: 60 * 60 * 24 * 7,
|
||||||
updateAge: 60 * 60 * 24,
|
updateAge: 60 * 60 * 24,
|
||||||
},
|
},
|
||||||
|
plugins: authentikEnabled
|
||||||
|
? [
|
||||||
|
genericOAuth({
|
||||||
|
config: [
|
||||||
|
{
|
||||||
|
providerId: "authentik",
|
||||||
|
discoveryUrl: authentikDiscoveryUrl(authentikIssuer!),
|
||||||
|
clientId: authentikClientId!,
|
||||||
|
clientSecret: authentikClientSecret!,
|
||||||
|
scopes: ["openid", "profile", "email"],
|
||||||
|
pkce: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
: [],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,7 +14,32 @@ import {
|
|||||||
ensureEventMembership,
|
ensureEventMembership,
|
||||||
ensureGroupMembership,
|
ensureGroupMembership,
|
||||||
} from "@/server/membership";
|
} from "@/server/membership";
|
||||||
import { hashToken } from "@/server/tokens";
|
import { hashToken, newInviteCode } from "@/server/tokens";
|
||||||
|
|
||||||
|
type InviteCodeValues = Omit<
|
||||||
|
typeof invites.$inferInsert,
|
||||||
|
"kind" | "tokenHash"
|
||||||
|
>;
|
||||||
|
|
||||||
|
export async function createInviteCode(
|
||||||
|
values: InviteCodeValues,
|
||||||
|
db: Database = getDb(),
|
||||||
|
) {
|
||||||
|
for (let attempt = 0; attempt < 10; attempt += 1) {
|
||||||
|
const code = newInviteCode();
|
||||||
|
const [created] = await db
|
||||||
|
.insert(invites)
|
||||||
|
.values({ ...values, kind: "code", tokenHash: hashToken(code) })
|
||||||
|
.onConflictDoNothing({ target: invites.tokenHash })
|
||||||
|
.returning({ id: invites.id });
|
||||||
|
if (created) return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
|
message: "Could not allocate an invite code",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function findInviteByToken(token: string, db: Database = getDb()) {
|
export async function findInviteByToken(token: string, db: Database = getDb()) {
|
||||||
const tokenHash = hashToken(token);
|
const tokenHash = hashToken(token);
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ await seedCredentialUser({
|
|||||||
password: "admin",
|
password: "admin",
|
||||||
});
|
});
|
||||||
await seedCredentialUser({
|
await seedCredentialUser({
|
||||||
name: "Maya Host",
|
name: "Demo Host",
|
||||||
email: "host@example.com",
|
email: "host@example.com",
|
||||||
password: "host",
|
password: "host",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { newInviteCode } from "./tokens";
|
||||||
|
|
||||||
|
describe("invite codes", () => {
|
||||||
|
test("uses four uppercase alphanumeric characters", () => {
|
||||||
|
for (let index = 0; index < 100; index += 1) {
|
||||||
|
expect(newInviteCode()).toMatch(/^[A-Z0-9]{4}$/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
import { createHash, randomBytes } from "node:crypto";
|
import { createHash, randomBytes, randomInt } from "node:crypto";
|
||||||
|
|
||||||
|
const INVITE_CODE_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
||||||
|
|
||||||
export function hashToken(token: string) {
|
export function hashToken(token: string) {
|
||||||
return createHash("sha256").update(token.trim().toLowerCase()).digest("hex");
|
return createHash("sha256").update(token.trim().toLowerCase()).digest("hex");
|
||||||
@@ -9,5 +11,8 @@ export function newToken(bytes = 24) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function newInviteCode() {
|
export function newInviteCode() {
|
||||||
return `VELLUM-${randomBytes(5).toString("hex").toUpperCase()}`;
|
return Array.from(
|
||||||
|
{ length: 4 },
|
||||||
|
() => INVITE_CODE_ALPHABET[randomInt(INVITE_CODE_ALPHABET.length)],
|
||||||
|
).join("");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -168,7 +168,7 @@ async function workerLoop(workerId: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.info(
|
console.info(
|
||||||
`Vellum worker listening for photo jobs (concurrency ${CONCURRENCY}, min interval ${MIN_INTERVAL_MS}ms)`,
|
`Manyangles worker listening for photo jobs (concurrency ${CONCURRENCY}, min interval ${MIN_INTERVAL_MS}ms)`,
|
||||||
);
|
);
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
|
||||||
|
<path
|
||||||
|
d="M14 4H8a4 4 0 0 0-4 4v6M18 4h6a4 4 0 0 1 4 4v6M28 18v6a4 4 0 0 1-4 4h-6M14 28H8a4 4 0 0 1-4-4v-6"
|
||||||
|
stroke="#3156c8"
|
||||||
|
stroke-width="2.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
/>
|
||||||
|
<circle cx="21" cy="11" r="2" fill="#3156c8" />
|
||||||
|
<path
|
||||||
|
d="m8.5 22 5-6.2a1.7 1.7 0 0 1 2.6-.1l2.6 2.9 1.7-1.8a1.7 1.7 0 0 1 2.5 0l1.6 1.8"
|
||||||
|
stroke="#3156c8"
|
||||||
|
stroke-width="2.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 518 B |
@@ -26,7 +26,7 @@ function chrome(bodyHtml: string) {
|
|||||||
<body style="margin:0;padding:24px 12px;background:#f6f1ea">
|
<body style="margin:0;padding:24px 12px;background:#f6f1ea">
|
||||||
<div style="max-width:560px;margin:0 auto;color:#2c2416;background:#fff;font-family:Georgia,serif">
|
<div style="max-width:560px;margin:0 auto;color:#2c2416;background:#fff;font-family:Georgia,serif">
|
||||||
<div style="padding:20px 24px;border:1px solid #e8dfd2;border-top:6px solid ${PRIMARY}">
|
<div style="padding:20px 24px;border:1px solid #e8dfd2;border-top:6px solid ${PRIMARY}">
|
||||||
<strong style="font-size:22px;letter-spacing:.04em">Vellum</strong>
|
<strong style="font-size:22px;letter-spacing:.04em">Manyangles</strong>
|
||||||
</div>
|
</div>
|
||||||
<div style="padding:32px;border:1px solid #e8dfd2;border-top:0">
|
<div style="padding:32px;border:1px solid #e8dfd2;border-top:0">
|
||||||
${bodyHtml}
|
${bodyHtml}
|
||||||
@@ -74,7 +74,7 @@ async function sendEmail(input: {
|
|||||||
const from =
|
const from =
|
||||||
process.env.EMAIL_FROM ??
|
process.env.EMAIL_FROM ??
|
||||||
process.env.RESEND_FROM ??
|
process.env.RESEND_FROM ??
|
||||||
"Vellum <photos@vellum.test>";
|
"Manyangles <photos@manyangles.test>";
|
||||||
const readiness = getEmailReadiness();
|
const readiness = getEmailReadiness();
|
||||||
if (process.env.NODE_ENV === "production" && !readiness.configured) {
|
if (process.env.NODE_ENV === "production" && !readiness.configured) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -125,11 +125,11 @@ export async function sendAccountVerificationEmail(message: {
|
|||||||
}) {
|
}) {
|
||||||
return sendEmail({
|
return sendEmail({
|
||||||
to: message.to,
|
to: message.to,
|
||||||
subject: "Verify your Vellum account",
|
subject: "Verify your Manyangles account",
|
||||||
html: chrome(`
|
html: chrome(`
|
||||||
<h1 style="margin:0 0 20px;font-size:28px">Verify your email</h1>
|
<h1 style="margin:0 0 20px;font-size:28px">Verify your email</h1>
|
||||||
<p>Hi ${escapeHtml(message.name)},</p>
|
<p>Hi ${escapeHtml(message.name)},</p>
|
||||||
<p>Confirm this address to finish creating your Vellum host account.</p>
|
<p>Confirm this address to finish creating your Manyangles host account.</p>
|
||||||
<p style="margin:24px 0">
|
<p style="margin:24px 0">
|
||||||
<a href="${escapeHtml(message.verificationUrl)}"
|
<a href="${escapeHtml(message.verificationUrl)}"
|
||||||
style="display:inline-block;padding:13px 20px;border-radius:6px;background:${PRIMARY};color:#fff;text-decoration:none;font-weight:700">
|
style="display:inline-block;padding:13px 20px;border-radius:6px;background:${PRIMARY};color:#fff;text-decoration:none;font-weight:700">
|
||||||
@@ -137,7 +137,7 @@ export async function sendAccountVerificationEmail(message: {
|
|||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
`),
|
`),
|
||||||
text: `Verify your Vellum email: ${message.verificationUrl}`,
|
text: `Verify your Manyangles email: ${message.verificationUrl}`,
|
||||||
referenceId: `account-verification-${referenceHash(message.verificationUrl)}`,
|
referenceId: `account-verification-${referenceHash(message.verificationUrl)}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -149,7 +149,7 @@ export async function sendPasswordResetEmail(message: {
|
|||||||
}) {
|
}) {
|
||||||
return sendEmail({
|
return sendEmail({
|
||||||
to: message.to,
|
to: message.to,
|
||||||
subject: "Reset your Vellum password",
|
subject: "Reset your Manyangles password",
|
||||||
html: chrome(`
|
html: chrome(`
|
||||||
<h1 style="margin:0 0 20px;font-size:28px">Reset your password</h1>
|
<h1 style="margin:0 0 20px;font-size:28px">Reset your password</h1>
|
||||||
<p>Hi ${escapeHtml(message.name)},</p>
|
<p>Hi ${escapeHtml(message.name)},</p>
|
||||||
@@ -164,7 +164,7 @@ export async function sendPasswordResetEmail(message: {
|
|||||||
If you did not request this, you can ignore this message.
|
If you did not request this, you can ignore this message.
|
||||||
</p>
|
</p>
|
||||||
`),
|
`),
|
||||||
text: `Reset your Vellum password: ${message.resetUrl}\n\nThis link expires in one hour.`,
|
text: `Reset your Manyangles password: ${message.resetUrl}\n\nThis link expires in one hour.`,
|
||||||
referenceId: `password-reset-${referenceHash(message.resetUrl)}`,
|
referenceId: `password-reset-${referenceHash(message.resetUrl)}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -176,7 +176,7 @@ export async function sendStaffInviteEmail(message: {
|
|||||||
}) {
|
}) {
|
||||||
return sendEmail({
|
return sendEmail({
|
||||||
to: message.to,
|
to: message.to,
|
||||||
subject: "You're invited to help with a Vellum event",
|
subject: "You're invited to help with a Manyangles event",
|
||||||
html: chrome(`
|
html: chrome(`
|
||||||
<h1 style="margin:0 0 20px;font-size:28px">You're invited</h1>
|
<h1 style="margin:0 0 20px;font-size:28px">You're invited</h1>
|
||||||
<p>${escapeHtml(message.inviterName)} invited you to help manage photos for an event.</p>
|
<p>${escapeHtml(message.inviterName)} invited you to help manage photos for an event.</p>
|
||||||
@@ -187,7 +187,7 @@ export async function sendStaffInviteEmail(message: {
|
|||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
`),
|
`),
|
||||||
text: `${message.inviterName} invited you to help with a Vellum event: ${message.inviteUrl}`,
|
text: `${message.inviterName} invited you to help with a Manyangles event: ${message.inviteUrl}`,
|
||||||
referenceId: `staff-invite-${referenceHash(message.inviteUrl)}`,
|
referenceId: `staff-invite-${referenceHash(message.inviteUrl)}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,9 @@
|
|||||||
"globalEnv": [
|
"globalEnv": [
|
||||||
"BETTER_AUTH_SECRET",
|
"BETTER_AUTH_SECRET",
|
||||||
"BETTER_AUTH_URL",
|
"BETTER_AUTH_URL",
|
||||||
|
"AUTHENTIK_ISSUER",
|
||||||
|
"AUTHENTIK_CLIENT_ID",
|
||||||
|
"AUTHENTIK_CLIENT_SECRET",
|
||||||
"DATABASE_URL",
|
"DATABASE_URL",
|
||||||
"NEXT_PUBLIC_APP_URL",
|
"NEXT_PUBLIC_APP_URL",
|
||||||
"EMAIL_PROVIDER",
|
"EMAIL_PROVIDER",
|
||||||
|
|||||||
Reference in New Issue
Block a user