From c3ff21e211ec0517f8850094ccfbe879f93e55a9 Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Fri, 13 Dec 2024 20:05:52 -0500 Subject: [PATCH] Feature/accounts (#99) * WIP * add migration for email tokens * add basic logout flow * added styling for log in popover * some styling to the log in button on home * super minimal add doc to homepage * added asChild prop to popover, tweaked spacing in mobile footer on home * added a settings popover for logged in users that contains log out button, streamlined the menu * smal tweaks * add errors states * sync local docs on login * use fixed values to determine leaflet preview size * fetch data for persisted docs * removed create from mobile footer in doc, fixed some small styling things inthe leaflet preview * flip order of migration add_more_get_fact_functions * add leaflets even if logging in to existing account * fix login adding existing docs and swr leaflet dat * positioned the warning banner on logged out home * add height to home page layout on mobile * styling the logged out banner to include the log in button and to appear in the right place on desktop * made bg of InputWithLabel transparent * overlay link on leaflet preview instead of nesting * move loggedOutWarning to seperate component and check leaflets * send login code with postmark * make removing leaflets from home work * import mutate once * add action file oops * make remove from home work! --------- Co-authored-by: celine Co-authored-by: Brendan Schlagel --- actions/createNewLeaflet.ts | 22 ++- actions/emailAuth.ts | 103 +++++++++++++ actions/getIdentityData.ts | 32 ++++ actions/getLeafletData.ts | 31 ++++ actions/login.ts | 131 ++++++++++++++++ actions/logout.ts | 8 + actions/removeLeafletFromHome.ts | 29 ++++ app/globals.css | 11 +- app/home/AccountSettings.tsx | 34 +++++ app/home/CreateNewButton.tsx | 6 +- app/home/HomeHelp.tsx | 56 +++---- app/home/LeafletList.tsx | 71 ++++++--- app/home/LeafletOptions.tsx | 30 +++- app/home/LeafletPreview.module.css | 16 ++ app/home/LeafletPreview.tsx | 37 ++--- app/home/LoggedOutWarning.tsx | 25 +++ app/home/page.tsx | 99 +++++++++--- app/layout.tsx | 5 +- app/login/LoginForm.tsx | 144 ++++++++++++++++++ app/login/page.tsx | 23 +++ components/Buttons.tsx | 35 +++-- components/HelpPopover.tsx | 113 +++++++------- components/HomeButton.tsx | 47 +++--- components/Icons.tsx | 39 +++++ components/IdentityProvider.tsx | 24 +++ components/IdentityProviderServer.tsx | 13 ++ components/Layout.tsx | 21 +++ components/LoginButton.tsx | 23 +++ components/MobileFooter.tsx | 2 - components/Pages/index.tsx | 6 +- components/Popover.tsx | 16 +- components/ThemeManager/ThemeSetter.tsx | 132 ++++++++-------- components/utils/AddLeafletToHomepage.tsx | 5 +- drizzle/relations.ts | 47 ++++-- drizzle/schema.ts | 63 +++++--- src/replicache/index.tsx | 4 +- supabase/database.types.ts | 96 ++++++++++++ ...0241210031519_add_more_identity_tables.sql | 62 ++++++++ ...1213050822_add_more_get_fact_functions.sql | 45 ++++++ 39 files changed, 1403 insertions(+), 303 deletions(-) create mode 100644 actions/emailAuth.ts create mode 100644 actions/getIdentityData.ts create mode 100644 actions/getLeafletData.ts create mode 100644 actions/login.ts create mode 100644 actions/logout.ts create mode 100644 actions/removeLeafletFromHome.ts create mode 100644 app/home/AccountSettings.tsx create mode 100644 app/home/LeafletPreview.module.css create mode 100644 app/home/LoggedOutWarning.tsx create mode 100644 app/login/LoginForm.tsx create mode 100644 app/login/page.tsx create mode 100644 components/IdentityProvider.tsx create mode 100644 components/IdentityProviderServer.tsx create mode 100644 components/LoginButton.tsx create mode 100644 supabase/migrations/20241210031519_add_more_identity_tables.sql create mode 100644 supabase/migrations/20241213050822_add_more_get_fact_functions.sql diff --git a/actions/createNewLeaflet.ts b/actions/createNewLeaflet.ts index 2d506240..2ea14213 100644 --- a/actions/createNewLeaflet.ts +++ b/actions/createNewLeaflet.ts @@ -3,21 +3,26 @@ import { drizzle } from "drizzle-orm/postgres-js"; import { entities, + identities, permission_tokens, permission_token_rights, entity_sets, facts, + permission_token_on_homepage, + email_auth_tokens, } from "drizzle/schema"; import { redirect } from "next/navigation"; import postgres from "postgres"; import { v7 } from "uuid"; -import { sql } from "drizzle-orm"; +import { sql, eq, and } from "drizzle-orm"; +import { cookies } from "next/headers"; export async function createNewLeaflet( pageType: "canvas" | "doc", redirectUser: boolean, ) { const client = postgres(process.env.DB_URL as string, { idle_timeout: 5 }); + let auth_token = cookies().get("auth_token")?.value; const db = drizzle(client); let { permissionToken } = await db.transaction(async (tx) => { // Create a new entity set @@ -116,6 +121,21 @@ export async function createNewLeaflet( }, ]); } + if (auth_token) { + await tx.execute(sql` + WITH auth_token AS ( + SELECT identities.id as identity_id + FROM email_auth_tokens + LEFT JOIN identities ON email_auth_tokens.identity = identities.id + WHERE email_auth_tokens.id = ${auth_token} + AND email_auth_tokens.confirmed = true + AND identities.id IS NOT NULL + ) + INSERT INTO permission_token_on_homepage (token, identity) + SELECT ${permissionToken.id}, identity_id + FROM auth_token + `); + } return { permissionToken, rights, root_entity, entity_set }; }); diff --git a/actions/emailAuth.ts b/actions/emailAuth.ts new file mode 100644 index 00000000..bc6b43e8 --- /dev/null +++ b/actions/emailAuth.ts @@ -0,0 +1,103 @@ +"use server"; + +import { randomBytes } from "crypto"; +import { drizzle } from "drizzle-orm/postgres-js"; +import postgres from "postgres"; +import { email_auth_tokens, identities } from "drizzle/schema"; +import { and, eq } from "drizzle-orm"; +import { cookies } from "next/headers"; + +async function sendAuthCode(email: string, code: string) { + let res = await fetch("https://api.postmarkapp.com/email", { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Postmark-Server-Token": process.env.POSTMARK_API_KEY!, + }, + body: JSON.stringify({ + From: "Leaflet ", + Subject: `Your authentication code for Leaflet is ${code}`, + To: email, + TextBody: `Paste this code to login to Leaflet: + +${code} + `, + }), + }); +} + +export async function requestAuthEmailToken(email: string) { + const client = postgres(process.env.DB_URL as string, { idle_timeout: 5 }); + const db = drizzle(client); + + const code = randomBytes(3).toString("hex").toUpperCase(); + + const [token] = await db + .insert(email_auth_tokens) + .values({ + email, + confirmation_code: code, + confirmed: false, + }) + .returning({ + id: email_auth_tokens.id, + }); + + await sendAuthCode(email, code); + + client.end(); + return token.id; +} + +export async function confirmEmailAuthToken(tokenId: string, code: string) { + const client = postgres(process.env.DB_URL as string, { idle_timeout: 5 }); + const db = drizzle(client); + + const [token] = await db + .select() + .from(email_auth_tokens) + .where(eq(email_auth_tokens.id, tokenId)); + + if (!token) { + client.end(); + return null; + } + + if (token.confirmation_code !== code) { + client.end(); + return null; + } + + if (token.confirmed) { + client.end(); + return null; + } + + let [identity] = await db + .select() + .from(identities) + .where(eq(identities.email, token.email)); + + const [confirmedToken] = await db + .update(email_auth_tokens) + .set({ + confirmed: true, + identity: identity?.id, + }) + .where( + and( + eq(email_auth_tokens.id, tokenId), + eq(email_auth_tokens.confirmation_code, code), + ), + ) + .returning(); + + cookies().set("auth_token", confirmedToken.id, { + secure: process.env.NODE_ENV === "production", + httpOnly: true, + sameSite: "strict", + }); + + client.end(); + return confirmedToken; +} diff --git a/actions/getIdentityData.ts b/actions/getIdentityData.ts new file mode 100644 index 00000000..25ce894d --- /dev/null +++ b/actions/getIdentityData.ts @@ -0,0 +1,32 @@ +"use server"; + +import { createServerClient } from "@supabase/ssr"; +import { cookies } from "next/headers"; +import { Database } from "supabase/database.types"; + +let supabase = createServerClient( + process.env.NEXT_PUBLIC_SUPABASE_API_URL as string, + process.env.SUPABASE_SERVICE_ROLE_KEY as string, + { cookies: {} }, +); +export async function getIdentityData() { + let cookieStore = cookies(); + let auth_token = cookieStore.get("auth_token")?.value; + let auth_res = auth_token + ? await supabase + .from("email_auth_tokens") + .select( + `*, + identities( + *, + home_leaflet:permission_tokens!identities_home_page_fkey(*, permission_token_rights(*)), + permission_token_on_homepage(created_at, permission_tokens!inner(*, permission_token_rights(*))) + )`, + ) + .eq("id", auth_token) + .eq("confirmed", true) + .single() + : null; + if (!auth_res?.data?.identities) return null; + return auth_res.data.identities; +} diff --git a/actions/getLeafletData.ts b/actions/getLeafletData.ts new file mode 100644 index 00000000..e2e994ae --- /dev/null +++ b/actions/getLeafletData.ts @@ -0,0 +1,31 @@ +"use server"; + +import { createServerClient } from "@supabase/ssr"; +import { Fact } from "src/replicache"; +import { Attributes } from "src/replicache/attributes"; +import { Database } from "supabase/database.types"; + +let supabase = createServerClient( + process.env.NEXT_PUBLIC_SUPABASE_API_URL as string, + process.env.SUPABASE_SERVICE_ROLE_KEY as string, + { cookies: {} }, +); +export async function getLeafletData(tokens: string[]) { + //Eventually check permission tokens in here somehow! + let all_facts = await supabase.rpc("get_facts_for_roots", { + max_depth: 3, + roots: tokens, + }); + if (all_facts.data) + return all_facts.data.reduce( + (acc, fact) => { + if (!acc[fact.root_id]) acc[fact.root_id] = []; + acc[fact.root_id].push( + fact as unknown as Fact, + ); + return acc; + }, + {} as { [key: string]: Fact[] }, + ); + return {}; +} diff --git a/actions/login.ts b/actions/login.ts new file mode 100644 index 00000000..d1d210da --- /dev/null +++ b/actions/login.ts @@ -0,0 +1,131 @@ +"use server"; +import { drizzle } from "drizzle-orm/postgres-js"; +import postgres from "postgres"; +import { + email_auth_tokens, + identities, + entity_sets, + entities, + permission_tokens, + permission_token_rights, + permission_token_on_homepage, +} from "drizzle/schema"; +import { and, eq, isNull } from "drizzle-orm"; +import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; +import { v7 } from "uuid"; + +export async function loginWithEmailToken( + localLeaflets: { token: { id: string }; added_at: string }[], +) { + const client = postgres(process.env.DB_URL as string, { idle_timeout: 5 }); + const db = drizzle(client); + let token_id = cookies().get("auth_token")?.value; + if (!token_id) return null; + let result = await db.transaction(async (tx) => { + let [token] = await tx + .select() + .from(email_auth_tokens) + .where( + and( + eq(email_auth_tokens.id, token_id), + eq(email_auth_tokens.confirmed, true), + ), + ); + if (!token) return null; + if (token.identity) { + let id = token.identity; + if (localLeaflets.length > 0) + await tx + .insert(permission_token_on_homepage) + .values( + localLeaflets.map((l) => ({ + identity: id, + token: l.token.id, + })), + ) + .onConflictDoNothing(); + return token; + } + let [existingIdentity] = await tx + .select() + .from(identities) + .where(eq(identities.email, token.email)); + + let identity = existingIdentity; + if (!existingIdentity) { + let identityCookie = cookies().get("identity"); + if (identityCookie) { + let [existingIdentityFromCookie] = await tx + .select() + .from(identities) + .where( + and( + eq(identities.id, identityCookie.value), + isNull(identities.email), + ), + ); + if (existingIdentityFromCookie) { + await tx + .update(identities) + .set({ email: token.email }) + .where(eq(identities.id, existingIdentityFromCookie.id)); + identity = existingIdentityFromCookie; + } + } else { + // Create a new entity set + let [entity_set] = await tx.insert(entity_sets).values({}).returning(); + // Create a root-entity + let [entity] = await tx + .insert(entities) + // And add it to that permission set + .values({ set: entity_set.id, id: v7() }) + .returning(); + //Create a new permission token + let [permissionToken] = await tx + .insert(permission_tokens) + .values({ root_entity: entity.id }) + .returning(); + //and give it all the permission on that entity set + let [rights] = await tx + .insert(permission_token_rights) + .values({ + token: permissionToken.id, + entity_set: entity_set.id, + read: true, + write: true, + create_token: true, + change_entity_set: true, + }) + .returning(); + let [newIdentity] = await tx + .insert(identities) + .values({ + home_page: permissionToken.id, + email: token.email, + }) + .returning(); + identity = newIdentity; + } + } + + await tx + .update(email_auth_tokens) + .set({ identity: identity.id }) + .where(eq(email_auth_tokens.id, token_id)); + + console.log( + await tx.insert(permission_token_on_homepage).values( + localLeaflets.map((l) => ({ + identity: identity.id, + token: l.token.id, + })), + ), + ); + + return token; + }); + + client.end(); + redirect("/home"); +} diff --git a/actions/logout.ts b/actions/logout.ts new file mode 100644 index 00000000..47bda554 --- /dev/null +++ b/actions/logout.ts @@ -0,0 +1,8 @@ +"use server"; + +import { cookies } from "next/headers"; + +export async function logout() { + cookies().delete("auth_token"); + cookies().delete("identity"); +} diff --git a/actions/removeLeafletFromHome.ts b/actions/removeLeafletFromHome.ts new file mode 100644 index 00000000..56e2fa4a --- /dev/null +++ b/actions/removeLeafletFromHome.ts @@ -0,0 +1,29 @@ +"use server"; + +import { drizzle } from "drizzle-orm/postgres-js"; +import { permission_token_on_homepage } from "drizzle/schema"; +import postgres from "postgres"; +import { v7 } from "uuid"; +import { sql, eq, inArray, and } from "drizzle-orm"; +import { cookies } from "next/headers"; +import { getIdentityData } from "./getIdentityData"; + +export async function removeLeafletFromHome(tokens: string[]) { + const identity = await getIdentityData(); + if (!identity) return null; + + const client = postgres(process.env.DB_URL as string, { idle_timeout: 5 }); + const db = drizzle(client); + + await db + .delete(permission_token_on_homepage) + .where( + and( + eq(permission_token_on_homepage.identity, identity.id), + inArray(permission_token_on_homepage.token, tokens), + ), + ); + + client.end(); + return true; +} diff --git a/app/globals.css b/app/globals.css index 0e8a8d04..dd679bda 100644 --- a/app/globals.css +++ b/app/globals.css @@ -180,7 +180,16 @@ input[type="number"] { @apply px-1; @apply py-0.5; @apply hover:border-tertiary; - @apply active:border-tertiary; + @apply focus:border-tertiary; + @apply focus:outline; + @apply focus:outline-tertiary; + @apply focus:outline-2; + @apply focus:outline-offset-1; + @apply focus-within:border-tertiary; + @apply focus-within:outline; + @apply focus-within:outline-tertiary; + @apply focus-within:outline-2; + @apply focus-within:outline-offset-1; @apply disabled:border-border-light; @apply disabled:bg-border-light; @apply disabled:text-tertiary; diff --git a/app/home/AccountSettings.tsx b/app/home/AccountSettings.tsx new file mode 100644 index 00000000..df688ad3 --- /dev/null +++ b/app/home/AccountSettings.tsx @@ -0,0 +1,34 @@ +"use client"; + +import { HoverButton } from "components/Buttons"; +import { AccountSmall, LogoutSmall } from "components/Icons"; +import { Menu, MenuItem } from "components/Layout"; +import { logout } from "actions/logout"; +import { mutate } from "swr"; + +// it was going have a popover with a log out button +export const AccountSettings = () => { + return ( + + label="Settings" + noLabelOnMobile + background="bg-accent-1" + text="text-accent-2" + /> + } + > + { + await logout(); + mutate("identity"); + }} + > + + Logout + + + ); +}; diff --git a/app/home/CreateNewButton.tsx b/app/home/CreateNewButton.tsx index 43a72bd1..853bad63 100644 --- a/app/home/CreateNewButton.tsx +++ b/app/home/CreateNewButton.tsx @@ -39,14 +39,16 @@ export const useTemplateState = create( }, ), ); -export const CreateNewLeafletButton = (props: {}) => { +export const CreateNewLeafletButton = (props: { + noLabelOnMobile?: boolean; +}) => { let templates = useTemplateState((s) => s.templates); return ( label="New Leaflet" background="bg-accent-1" diff --git a/app/home/HomeHelp.tsx b/app/home/HomeHelp.tsx index 4e03b266..b817f602 100644 --- a/app/home/HomeHelp.tsx +++ b/app/home/HomeHelp.tsx @@ -1,47 +1,35 @@ "use client"; -import { InfoSmall, PopoverArrow } from "components/Icons"; +import { HelpSmall } from "components/Icons"; import { HoverButton } from "components/Buttons"; -import * as Popover from "@radix-ui/react-popover"; +import { Popover } from "components/Popover"; export const HomeHelp = () => { return ( - - + } + icon={} noLabelOnMobile label="Info" background="bg-accent-1" text="text-accent-2" /> - - - -
-

- Leaflets are saved to home per-device / browser{" "} - using cookies. -

-

- - If you clear your cookies, they'll disappear. - -

-

- Please contact us{" "} - for help recovering Leaflets! -

-
- - - -
-
-
+ } + > +
+

+ Leaflets are saved to home per-device / browser using + cookies. +

+

+ If you clear your cookies, they'll disappear. +

+

+ Please contact us for + help recovering Leaflets! +

+
+ ); }; diff --git a/app/home/LeafletList.tsx b/app/home/LeafletList.tsx index 786f7009..8408d893 100644 --- a/app/home/LeafletList.tsx +++ b/app/home/LeafletList.tsx @@ -3,34 +3,65 @@ import { useEffect, useState } from "react"; import { getHomeDocs, HomeDoc } from "./storage"; import useSWR from "swr"; -import { ReplicacheProvider } from "src/replicache"; +import { Fact, ReplicacheProvider } from "src/replicache"; import { LeafletPreview } from "./LeafletPreview"; +import { useIdentityData } from "components/IdentityProvider"; +import { Attributes } from "src/replicache/attributes"; +import { getLeafletData } from "actions/getLeafletData"; +import { getIdentityData } from "actions/getIdentityData"; -export function LeafletList() { - let { data: leaflets } = useSWR("leaflets", () => getHomeDocs(), { +export function LeafletList(props: { + initialFacts: { + [root_entity: string]: Fact[]; + }; +}) { + let { data: localLeaflets } = useSWR("leaflets", () => getHomeDocs(), { fallbackData: [], }); + let { identity } = useIdentityData(); + let { data: initialFacts, mutate } = useSWR( + "home-leaflet-data", + () => { + if (identity) + return getLeafletData( + identity.permission_token_on_homepage.map( + (ptrh) => ptrh.permission_tokens.root_entity, + ), + ); + }, + { fallbackData: props.initialFacts }, + ); + useEffect(() => { + mutate(); + }, [localLeaflets.length, mutate]); + let leaflets = identity + ? identity.permission_token_on_homepage + .sort((a, b) => (a.created_at > b.created_at ? -1 : 1)) + .map((ptoh) => ptoh.permission_tokens) + : localLeaflets + .sort((a, b) => (a.added_at > b.added_at ? -1 : 1)) + .filter((d) => !d.hidden) + .map((ll) => ll.token); return (
-
- {leaflets - .sort((a, b) => (a.added_at > b.added_at ? -1 : 1)) - .filter((d) => !d.hidden) - .map(({ token: leaflet }) => ( - + {leaflets.map((leaflet) => ( + + - - - ))} + leaflet_id={leaflet.root_entity} + loggedIn={!!identity} + /> + + ))}
); diff --git a/app/home/LeafletOptions.tsx b/app/home/LeafletOptions.tsx index 1bb29b2d..fcfcc79c 100644 --- a/app/home/LeafletOptions.tsx +++ b/app/home/LeafletOptions.tsx @@ -8,18 +8,21 @@ import { } from "components/Icons"; import { Menu, MenuItem } from "components/Layout"; import { PermissionToken } from "src/replicache"; -import { mutate } from "swr"; import { hideDoc } from "./storage"; import { useState } from "react"; import { ButtonPrimary } from "components/Buttons"; import { useTemplateState } from "./CreateNewButton"; import { Item } from "@radix-ui/react-dropdown-menu"; import { useSmoker } from "components/Toast"; +import { removeLeafletFromHome } from "actions/removeLeafletFromHome"; +import { useIdentityData } from "components/IdentityProvider"; export const LeafletOptions = (props: { leaflet: PermissionToken; isTemplate: boolean; + loggedIn: boolean; }) => { + let { mutate } = useIdentityData(); let [state, setState] = useState<"normal" | "template">("normal"); let [open, setOpen] = useState(false); let smoker = useSmoker(); @@ -71,9 +74,28 @@ export const LeafletOptions = (props: { )} { - hideDoc(props.leaflet); - mutate("leaflets"); + onSelect={async () => { + console.log(props.loggedIn); + if (props.loggedIn) { + mutate( + (s) => { + if (!s) return s; + return { + ...s, + permission_token_on_homepage: + s.permission_token_on_homepage.filter( + (ptrh) => + ptrh.permission_tokens.id !== props.leaflet.id, + ), + }; + }, + { revalidate: false }, + ); + await removeLeafletFromHome([props.leaflet.id]); + mutate(); + } else { + hideDoc(props.leaflet); + } }} > diff --git a/app/home/LeafletPreview.module.css b/app/home/LeafletPreview.module.css new file mode 100644 index 00000000..fbe225ec --- /dev/null +++ b/app/home/LeafletPreview.module.css @@ -0,0 +1,16 @@ +@media (min-width: 640px) { + .scaleLeafletDocPreview { + transform: scale(calc(192 / var(--page-width-unitless))); + } + .scaleLeafletCanvasPreview { + transform: scale(calc(192 / 1272)); + } +} + +.scaleLeafletDocPreview { + transform: scale(calc(160 / var(--page-width-unitless))); +} + +.scaleLeafletCanvasPreview { + transform: scale(calc(160 / 1272)); +} diff --git a/app/home/LeafletPreview.tsx b/app/home/LeafletPreview.tsx index 040fb1c1..753b8864 100644 --- a/app/home/LeafletPreview.tsx +++ b/app/home/LeafletPreview.tsx @@ -16,7 +16,6 @@ import { import { deleteLeaflet } from "actions/deleteLeaflet"; import { removeDocFromHome } from "./storage"; import { mutate } from "swr"; -import useMeasure from "react-use-measure"; import { ButtonPrimary } from "components/Buttons"; import { LeafletOptions } from "./LeafletOptions"; import { CanvasContent } from "components/Canvas"; @@ -24,10 +23,12 @@ import { useSubscribe } from "replicache-react"; import { TemplateSmall } from "components/Icons"; import { theme } from "tailwind.config"; import { useTemplateState } from "./CreateNewButton"; +import styles from "./LeafletPreview.module.css"; export const LeafletPreview = (props: { token: PermissionToken; leaflet_id: string; + loggedIn: boolean; }) => { let [state, setState] = useState<"normal" | "deleting">("normal"); let isTemplate = useTemplateState( @@ -42,16 +43,17 @@ export const LeafletPreview = (props: { return (
-
+
{state === "normal" ? ( - +
+
- +
) : ( )}
- +
@@ -79,20 +85,17 @@ const LeafletContent = (props: { entityID: string }) => { let type = useEntity(props.entityID, "page/type")?.data.value || "doc"; let blocks = useBlocks(props.entityID); let previewRef = useRef(null); - let [ref, dimensions] = useMeasure(); if (type === "canvas") return (
@@ -103,14 +106,12 @@ const LeafletContent = (props: { entityID: string }) => { return (
-
{blocks.slice(0, 10).map((b, index, arr) => { diff --git a/app/home/LoggedOutWarning.tsx b/app/home/LoggedOutWarning.tsx new file mode 100644 index 00000000..8b760195 --- /dev/null +++ b/app/home/LoggedOutWarning.tsx @@ -0,0 +1,25 @@ +"use client"; +import { useIdentityData } from "components/IdentityProvider"; +import { LoginButton } from "components/LoginButton"; + +export const LoggedOutWarning = (props: {}) => { + let { identity } = useIdentityData(); + if (identity) return null; + return ( +
+
+

+ Log in to collect all your Leaflets and access them on multiple + devices +

+ +
+
+ ); +}; diff --git a/app/home/page.tsx b/app/home/page.tsx index 6dac91b1..534e0932 100644 --- a/app/home/page.tsx +++ b/app/home/page.tsx @@ -1,4 +1,3 @@ -import { AddTiny } from "components/Icons"; import { cookies } from "next/headers"; import { Fact, ReplicacheProvider } from "src/replicache"; import { createServerClient } from "@supabase/ssr"; @@ -17,6 +16,11 @@ import { IdentitySetter } from "./IdentitySetter"; import { HomeHelp } from "./HomeHelp"; import { LeafletList } from "./LeafletList"; import { CreateNewLeafletButton } from "./CreateNewButton"; +import { getIdentityData } from "actions/getIdentityData"; +import { LoginButton } from "components/LoginButton"; +import { HelpPopover } from "components/HelpPopover"; +import { AccountSettings } from "./AccountSettings"; +import { LoggedOutWarning } from "./LoggedOutWarning"; let supabase = createServerClient( process.env.NEXT_PUBLIC_SUPABASE_API_URL as string, @@ -25,7 +29,12 @@ let supabase = createServerClient( ); export default async function Home() { let cookieStore = cookies(); - let identity = cookieStore.get("identity")?.value; + + let auth_token = cookieStore.get("auth_token")?.value; + let auth_res = auth_token ? await getIdentityData() : null; + let identity: string | undefined; + if (auth_res) identity = auth_res.id; + else identity = cookieStore.get("identity")?.value; let needstosetcookie = false; if (!identity) { const client = postgres(process.env.DB_URL as string, { idle_timeout: 5 }); @@ -42,43 +51,87 @@ export default async function Home() { cookies().set("identity", identity as string, { sameSite: "strict" }); } - let res = await supabase - .from("identities") - .select( - `*, - permission_tokens!identities_home_page_fkey(*, permission_token_rights(*)) - `, - ) - .eq("id", identity) - .single(); - if (!res.data) return
{JSON.stringify(res.error)}
; - if (!res.data.permission_tokens) return
no home page wierdly
; + let permission_token = auth_res?.home_leaflet; + if (!permission_token) { + let res = await supabase + .from("identities") + .select( + `*, + permission_tokens!identities_home_page_fkey(*, permission_token_rights(*)) + `, + ) + .eq("id", identity) + .single(); + permission_token = res.data?.permission_tokens; + } + + if (!permission_token) return
no home page wierdly
; let { data } = await supabase.rpc("get_facts", { - root: res.data.permission_tokens?.root_entity, + root: permission_token.root_entity, }); let initialFacts = (data as unknown as Fact[]) || []; - let root_entity = res.data.permission_tokens.root_entity; + + let root_entity = permission_token.root_entity; + let home_docs_initialFacts: { + [root_entity: string]: Fact[]; + } = {}; + if (auth_res) { + let all_facts = await supabase.rpc("get_facts_for_roots", { + max_depth: 3, + roots: auth_res.permission_token_on_homepage.map( + (r) => r.permission_tokens.root_entity, + ), + }); + if (all_facts.data) + home_docs_initialFacts = all_facts.data.reduce( + (acc, fact) => { + if (!acc[fact.root_id]) acc[fact.root_id] = []; + acc[fact.root_id].push( + fact as unknown as Fact, + ); + return acc; + }, + {} as { [key: string]: Fact[] }, + ); + } return (
-
-
- - - +
+ {!auth_res && ( +
+ +
+ )} +
+
+ + + + {auth_res && } +
+
+
+ {!auth_res && ( +
+ +
+ )} +
-
diff --git a/app/layout.tsx b/app/layout.tsx index 7c12be5e..2af97907 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -5,6 +5,7 @@ import { Analytics } from "@vercel/analytics/react"; import "./globals.css"; import localFont from "next/font/local"; import { PopUpProvider } from "components/Toast"; +import { IdentityProviderServer } from "components/IdentityProviderServer"; export const metadata = { title: "Leaflet", @@ -63,7 +64,9 @@ export default function RootLayout({ - {children} + + {children} + diff --git a/app/login/LoginForm.tsx b/app/login/LoginForm.tsx new file mode 100644 index 00000000..725b6c16 --- /dev/null +++ b/app/login/LoginForm.tsx @@ -0,0 +1,144 @@ +"use client"; +import { + confirmEmailAuthToken, + requestAuthEmailToken, +} from "actions/emailAuth"; +import { loginWithEmailToken } from "actions/login"; +import { getHomeDocs } from "app/home/storage"; +import { ButtonPrimary } from "components/Buttons"; +import { InputWithLabel } from "components/Layout"; +import { useSmoker, useToaster } from "components/Toast"; +import React, { useState } from "react"; +import useSWR, { mutate } from "swr"; + +export default function LoginForm() { + type FormState = + | { + stage: "email"; + email: string; + } + | { + stage: "code"; + email: string; + tokenId: string; + confirmationCode: string; + }; + + const [formState, setFormState] = useState({ + stage: "email", + email: "", + }); + + let { data: localLeaflets } = useSWR("leaflets", () => getHomeDocs(), { + fallbackData: [], + }); + + const handleSubmitEmail = async (e: React.FormEvent) => { + e.preventDefault(); + const tokenId = await requestAuthEmailToken(formState.email); + setFormState({ + stage: "code", + email: formState.email, + tokenId, + confirmationCode: "", + }); + }; + + let smoker = useSmoker(); + let toaster = useToaster(); + + const handleSubmitCode = async (e: React.FormEvent) => { + e.preventDefault(); + let rect = e.currentTarget.getBoundingClientRect(); + + if (formState.stage !== "code") return; + const confirmedToken = await confirmEmailAuthToken( + formState.tokenId, + formState.confirmationCode, + ); + + if (!confirmedToken) { + smoker({ + error: true, + text: "incorrect code!", + position: { + y: rect.bottom - 16, + x: rect.right - 220, + }, + }); + } else { + await loginWithEmailToken(localLeaflets.filter((l) => !l.hidden)); + mutate("identity"); + toaster({ + content:
Logged in! Welcome!
, + type: "success", + }); + } + }; + + if (formState.stage === "code") { + return ( +
+
+ Please enter the code we sent to +
{formState.email}
+
+
+ + setFormState({ + ...formState, + confirmationCode: e.target.value, + }) + } + required + /> + + {}} + > + Confirm + + +
+ ); + } + + return ( +
+
+

Log In or Sign Up

+
+ Save your leaflets and access them on multiple devices! +
+
+
+ + setFormState({ + ...formState, + email: e.target.value, + }) + } + required + /> + + + Log In / Sign Up + + +
+ ); +} diff --git a/app/login/page.tsx b/app/login/page.tsx new file mode 100644 index 00000000..ff4dc429 --- /dev/null +++ b/app/login/page.tsx @@ -0,0 +1,23 @@ +import { cookies } from "next/headers"; +import LoginForm from "./LoginForm"; +import { logout } from "actions/logout"; + +export default function LoginPage() { + let cookieStore = cookies(); + let identity = cookieStore.get("auth_token")?.value; + if (!identity) + return ( +
+ this is a login page! + +
+ ); + return ( +
+ identity: {identity} +
+ +
+
+ ); +} diff --git a/components/Buttons.tsx b/components/Buttons.tsx index b70dadee..1c5efc02 100644 --- a/components/Buttons.tsx +++ b/components/Buttons.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { forwardRef } from "react"; import * as RadixTooltip from "@radix-ui/react-tooltip"; import { theme } from "tailwind.config"; import { PopoverArrow } from "./Icons"; @@ -8,29 +8,36 @@ import { } from "./ThemeManager/ThemeProvider"; type ButtonProps = Omit; -export function ButtonPrimary( - props: { +export const ButtonPrimary = forwardRef< + HTMLButtonElement, + ButtonProps & { fullWidth?: boolean; + fullWidthOnMobile?: boolean; children: React.ReactNode; compact?: boolean; - } & ButtonProps, -) { + } +>((props, ref) => { return ( ); -} +}); +ButtonPrimary.displayName = "ButtonPrimary"; export const HoverButton = (props: { id?: string; diff --git a/components/HelpPopover.tsx b/components/HelpPopover.tsx index ff95a333..184942c4 100644 --- a/components/HelpPopover.tsx +++ b/components/HelpPopover.tsx @@ -1,3 +1,4 @@ +"use client"; import { isMac } from "@react-aria/utils"; import { HelpSmall } from "./Icons"; import { ShortcutKey } from "./Layout"; @@ -9,7 +10,7 @@ import Link from "next/link"; import { useState } from "react"; import { HoverButton } from "./Buttons"; -export const HelpPopover = () => { +export const HelpPopover = (props: { noShortcuts?: boolean }) => { let entity_set = useEntitySetContext(); return entity_set.permissions.write ? ( { url="https://leaflet.pub/legal" /> -
-
- - - - - - - + {!props.noShortcuts && ( + <> +
+
+ + + + + + + - - {/* shift + up/down arrows (or click + drag): select multiple blocks */} - - - {/* cmd/ctrl-a: first selects all text in a block; again selects all blocks on page */} - {/* cmd/ctrl + up/down arrows: go to beginning / end of doc */} + + {/* shift + up/down arrows (or click + drag): select multiple blocks */} + + + {/* cmd/ctrl-a: first selects all text in a block; again selects all blocks on page */} + {/* cmd/ctrl + up/down arrows: go to beginning / end of doc */} - - - + + + - - - {/* tab / shift + tab: indent / outdent */} - - - - -
+ + + {/* tab / shift + tab: indent / outdent */} + + + + +
+ + )}
diff --git a/components/HomeButton.tsx b/components/HomeButton.tsx index db1bde55..13a4855a 100644 --- a/components/HomeButton.tsx +++ b/components/HomeButton.tsx @@ -11,28 +11,31 @@ export function HomeButton() { let params = useParams(); let isSubpage = !!searchParams.get("page"); - if (isSubpage) + if (permissions.write) return ( - - } - label="See Full Leaflet" - background="bg-accent-1" - text="text-accent-2" - /> - + <> + {isSubpage && ( + + } + label="See Full Leaflet" + background="bg-accent-1" + text="text-accent-2" + /> + + )} + + + } + label="Go Home" + background="bg-accent-1" + text="text-accent-2" + /> + + ); - if (!permissions.write) return null; - return ( - - } - label="Go Home" - background="bg-accent-1" - text="text-accent-2" - /> - - ); + return null; } diff --git a/components/Icons.tsx b/components/Icons.tsx index 89dd0963..c3530f15 100644 --- a/components/Icons.tsx +++ b/components/Icons.tsx @@ -445,6 +445,26 @@ export const LogoSmall = ( ); }; +export const LogoutSmall = (props: Props) => { + return ( + + + + ); +}; + export const PaintSmall = (props: Props) => { return ( { ); }; +export const AccountSmall = (props: Props) => { + return ( + + + + ); +}; export const ShareSmall = (props: Props) => { return ( >; +let IdentityContext = createContext({ + identity: null as Identity, + mutate: (() => {}) as KeyedMutator, +}); +export const useIdentityData = () => useContext(IdentityContext); +export function IdentityContextProvider(props: { + children: React.ReactNode; + initialValue: Identity; +}) { + let { data: identity, mutate } = useSWR("identity", () => getIdentityData(), { + fallbackData: props.initialValue, + }); + return ( + + {props.children} + + ); +} diff --git a/components/IdentityProviderServer.tsx b/components/IdentityProviderServer.tsx new file mode 100644 index 00000000..ce3929d9 --- /dev/null +++ b/components/IdentityProviderServer.tsx @@ -0,0 +1,13 @@ +import { getIdentityData } from "actions/getIdentityData"; +import { IdentityContextProvider } from "./IdentityProvider"; + +export async function IdentityProviderServer(props: { + children: React.ReactNode; +}) { + let identity = await getIdentityData(); + return ( + + {props.children} + + ); +} diff --git a/components/Layout.tsx b/components/Layout.tsx index 6705bd53..0ae23217 100644 --- a/components/Layout.tsx +++ b/components/Layout.tsx @@ -85,6 +85,27 @@ export const MenuItem = (props: { ); }; +export const InputWithLabel = ( + props: { + label: string; + } & JSX.IntrinsicElements["input"], +) => { + let { label, ...inputProps } = props; + return ( +
+
+ +
+
+ ); +}; + export const ShortcutKey = (props: { children: React.ReactNode }) => { return ( diff --git a/components/LoginButton.tsx b/components/LoginButton.tsx new file mode 100644 index 00000000..b2b8470b --- /dev/null +++ b/components/LoginButton.tsx @@ -0,0 +1,23 @@ +"use client"; +import { logout } from "actions/logout"; +import { useIdentityData } from "./IdentityProvider"; +import { Popover } from "./Popover"; +import LoginForm from "app/login/LoginForm"; +import { ButtonPrimary } from "./Buttons"; + +export function LoginButton() { + let identityData = useIdentityData(); + if (identityData.identity) return null; + return ( + + Log In! + + } + > + + + ); +} diff --git a/components/MobileFooter.tsx b/components/MobileFooter.tsx index 226a803e..8d498f39 100644 --- a/components/MobileFooter.tsx +++ b/components/MobileFooter.tsx @@ -7,7 +7,6 @@ import { ShareOptions } from "./ShareOptions"; import { HomeButton } from "./HomeButton"; import { useEntitySetContext } from "./EntitySetProvider"; import { HelpPopover } from "./HelpPopover"; -import { CreateNewLeafletButton } from "app/home/CreateNewButton"; import { Watermark } from "./Watermark"; export function MobileFooter(props: { entityID: string }) { @@ -35,7 +34,6 @@ export function MobileFooter(props: { entityID: string }) {
-
diff --git a/components/Pages/index.tsx b/components/Pages/index.tsx index 823e936b..ed3e2bed 100644 --- a/components/Pages/index.tsx +++ b/components/Pages/index.tsx @@ -28,13 +28,13 @@ import { Blocks } from "components/Blocks"; import { MenuItem, Menu } from "../Layout"; import { MoreOptionsTiny, CloseTiny, PaintSmall, ShareSmall } from "../Icons"; import { HelpPopover } from "../HelpPopover"; -import { CreateNewLeafletButton } from "app/home/CreateNewButton"; import { scanIndex } from "src/replicache/utils"; import { PageThemeSetter } from "../ThemeManager/PageThemeSetter"; import { CardThemeProvider } from "../ThemeManager/ThemeProvider"; import { PageShareMenu } from "./PageShareMenu"; import { Watermark } from "components/Watermark"; import { scrollIntoViewIfNeeded } from "src/utils/scrollIntoViewIfNeeded"; +import { LoginButton } from "components/LoginButton"; export function Pages(props: { rootPage: string }) { let rootPage = useEntity(props.rootPage, "root/page")[0]; @@ -65,15 +65,13 @@ export function Pages(props: { rootPage: string }) {
-
) : (
- {" "} - {" "} +
)} diff --git a/components/Popover.tsx b/components/Popover.tsx index 69250384..04c716cc 100644 --- a/components/Popover.tsx +++ b/components/Popover.tsx @@ -12,16 +12,26 @@ export const Popover = (props: { border?: string; className?: string; open?: boolean; + onOpenChange?: (open: boolean) => void; + asChild?: boolean; }) => { return ( - - + + {props.trigger} { Example Button
- {!props.home && ( - <> -
-
- setOpenPicker("null")} - /> - setOpenPicker("null")} - /> -
- -
-
{ - e.currentTarget === e.target && setOpenPicker("page"); - }} - className="rounded-t-lg cursor-pointer p-2 border border-border border-b-transparent shadow-md text-primary" - style={{ - backgroundColor: - "rgba(var(--bg-page), var(--bg-page-alpha))", - }} - > -

{ - setOpenPicker("text"); - }} - className=" cursor-pointer font-bold w-fit" - > - Hello! -

- setOpenPicker("text")}> - Welcome to{" "} - - Leaflet - - . It's a super easy and fun way to make, share, and - collab on little bits of paper - -
- - )} +
+
+ setOpenPicker("null")} + /> + setOpenPicker("null")} + /> +
+ +
+ +
- + {!props.home && }
); } + +const SamplePage = (props: { + home: boolean | undefined; + setOpenPicker: (picker: "page" | "text") => void; +}) => { + return ( +
{ + e.currentTarget === e.target && props.setOpenPicker("page"); + }} + className={`${props.home ? "rounded-md " : "rounded-t-lg "} cursor-pointer p-2 border border-border border-b-transparent shadow-md text-primary`} + style={{ + backgroundColor: "rgba(var(--bg-page), var(--bg-page-alpha))", + }} + > +

{ + props.setOpenPicker("text"); + }} + className=" cursor-pointer font-bold w-fit" + > + Hello! +

+ props.setOpenPicker("text")}> + Welcome to{" "} + Leaflet. + It's a super easy and fun way to make, share, and collab on little + bits of paper + +
+ ); +}; + let thumbStyle = "w-4 h-4 rounded-full border-2 border-white shadow-[0_0_0_1px_#8C8C8C,_inset_0_0_0_1px_#8C8C8C]"; @@ -602,7 +606,7 @@ export const LeafletBGPicker = (props: {
{open && ( -
+
{ + if (identity) return; if (permission_token.permission_token_rights[0].write) { try { addDocToHome(permission_token); } catch (e) {} } - }, [permission_token]); + }, [permission_token, identity]); return null; } diff --git a/drizzle/relations.ts b/drizzle/relations.ts index 0b8cea02..3e7d47ee 100644 --- a/drizzle/relations.ts +++ b/drizzle/relations.ts @@ -1,14 +1,14 @@ import { relations } from "drizzle-orm/relations"; -import { entity_sets, entities, email_subscriptions_to_entity, permission_tokens, identities, facts, permission_token_rights } from "./schema"; +import { entity_sets, entities, facts, permission_tokens, identities, email_subscriptions_to_entity, email_auth_tokens, permission_token_on_homepage, permission_token_rights } from "./schema"; export const entitiesRelations = relations(entities, ({one, many}) => ({ entity_set: one(entity_sets, { fields: [entities.set], references: [entity_sets.id] }), - email_subscriptions_to_entities: many(email_subscriptions_to_entity), - permission_tokens: many(permission_tokens), facts: many(facts), + permission_tokens: many(permission_tokens), + email_subscriptions_to_entities: many(email_subscriptions_to_entity), })); export const entity_setsRelations = relations(entity_sets, ({many}) => ({ @@ -16,38 +16,59 @@ export const entity_setsRelations = relations(entity_sets, ({many}) => ({ permission_token_rights: many(permission_token_rights), })); -export const email_subscriptions_to_entityRelations = relations(email_subscriptions_to_entity, ({one}) => ({ +export const factsRelations = relations(facts, ({one}) => ({ entity: one(entities, { - fields: [email_subscriptions_to_entity.entity], + fields: [facts.entity], references: [entities.id] }), +})); + +export const identitiesRelations = relations(identities, ({one, many}) => ({ permission_token: one(permission_tokens, { - fields: [email_subscriptions_to_entity.token], + fields: [identities.home_page], references: [permission_tokens.id] }), + email_auth_tokens: many(email_auth_tokens), + permission_token_on_homepages: many(permission_token_on_homepage), })); export const permission_tokensRelations = relations(permission_tokens, ({one, many}) => ({ - email_subscriptions_to_entities: many(email_subscriptions_to_entity), identities: many(identities), entity: one(entities, { fields: [permission_tokens.root_entity], references: [entities.id] }), + email_subscriptions_to_entities: many(email_subscriptions_to_entity), + permission_token_on_homepages: many(permission_token_on_homepage), permission_token_rights: many(permission_token_rights), })); -export const identitiesRelations = relations(identities, ({one}) => ({ +export const email_subscriptions_to_entityRelations = relations(email_subscriptions_to_entity, ({one}) => ({ + entity: one(entities, { + fields: [email_subscriptions_to_entity.entity], + references: [entities.id] + }), permission_token: one(permission_tokens, { - fields: [identities.home_page], + fields: [email_subscriptions_to_entity.token], references: [permission_tokens.id] }), })); -export const factsRelations = relations(facts, ({one}) => ({ - entity: one(entities, { - fields: [facts.entity], - references: [entities.id] +export const email_auth_tokensRelations = relations(email_auth_tokens, ({one}) => ({ + identity: one(identities, { + fields: [email_auth_tokens.identity], + references: [identities.id] + }), +})); + +export const permission_token_on_homepageRelations = relations(permission_token_on_homepage, ({one}) => ({ + identity: one(identities, { + fields: [permission_token_on_homepage.identity], + references: [identities.id] + }), + permission_token: one(permission_tokens, { + fields: [permission_token_on_homepage.token], + references: [permission_tokens.id] }), })); diff --git a/drizzle/schema.ts b/drizzle/schema.ts index 4339885f..fb25bbf0 100644 --- a/drizzle/schema.ts +++ b/drizzle/schema.ts @@ -1,4 +1,4 @@ -import { pgTable, pgEnum, text, bigint, foreignKey, uuid, timestamp, boolean, jsonb, primaryKey } from "drizzle-orm/pg-core" +import { pgTable, foreignKey, pgEnum, uuid, timestamp, text, jsonb, bigint, boolean, primaryKey } from "drizzle-orm/pg-core" import { sql } from "drizzle-orm" export const aal_level = pgEnum("aal_level", ['aal1', 'aal2', 'aal3']) @@ -13,6 +13,23 @@ export const action = pgEnum("action", ['INSERT', 'UPDATE', 'DELETE', 'TRUNCATE' export const equality_op = pgEnum("equality_op", ['eq', 'neq', 'lt', 'lte', 'gt', 'gte', 'in']) +export const entities = pgTable("entities", { + id: uuid("id").primaryKey().notNull(), + created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), + set: uuid("set").notNull().references(() => entity_sets.id, { onDelete: "cascade", onUpdate: "cascade" } ), +}); + +export const facts = pgTable("facts", { + id: uuid("id").primaryKey().notNull(), + entity: uuid("entity").notNull().references(() => entities.id, { onDelete: "cascade", onUpdate: "restrict" } ), + attribute: text("attribute").notNull(), + data: jsonb("data").notNull(), + created_at: timestamp("created_at", { mode: 'string' }).defaultNow().notNull(), + updated_at: timestamp("updated_at", { mode: 'string' }), + // You can use { mode: "bigint" } if numbers are exceeding js number limitations + version: bigint("version", { mode: "number" }).default(0).notNull(), +}); + export const replicache_clients = pgTable("replicache_clients", { client_id: text("client_id").primaryKey().notNull(), client_group: text("client_group").notNull(), @@ -20,15 +37,21 @@ export const replicache_clients = pgTable("replicache_clients", { last_mutation: bigint("last_mutation", { mode: "number" }).notNull(), }); -export const entities = pgTable("entities", { - id: uuid("id").primaryKey().notNull(), +export const entity_sets = pgTable("entity_sets", { + id: uuid("id").defaultRandom().primaryKey().notNull(), created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), - set: uuid("set").notNull().references(() => entity_sets.id, { onDelete: "cascade", onUpdate: "cascade" } ), }); -export const entity_sets = pgTable("entity_sets", { +export const identities = pgTable("identities", { id: uuid("id").defaultRandom().primaryKey().notNull(), created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), + home_page: uuid("home_page").notNull().references(() => permission_tokens.id, { onDelete: "cascade" } ), + email: text("email"), +}); + +export const permission_tokens = pgTable("permission_tokens", { + id: uuid("id").defaultRandom().primaryKey().notNull(), + root_entity: uuid("root_entity").notNull().references(() => entities.id, { onDelete: "cascade", onUpdate: "cascade" } ), }); export const email_subscriptions_to_entity = pgTable("email_subscriptions_to_entity", { @@ -41,26 +64,24 @@ export const email_subscriptions_to_entity = pgTable("email_subscriptions_to_ent confirmation_code: text("confirmation_code").notNull(), }); -export const identities = pgTable("identities", { +export const email_auth_tokens = pgTable("email_auth_tokens", { id: uuid("id").defaultRandom().primaryKey().notNull(), created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), - home_page: uuid("home_page").notNull().references(() => permission_tokens.id, { onDelete: "cascade" } ), -}); - -export const permission_tokens = pgTable("permission_tokens", { - id: uuid("id").defaultRandom().primaryKey().notNull(), - root_entity: uuid("root_entity").notNull().references(() => entities.id, { onDelete: "cascade", onUpdate: "cascade" } ), + confirmed: boolean("confirmed").default(false).notNull(), + email: text("email").notNull(), + confirmation_code: text("confirmation_code").notNull(), + identity: uuid("identity").references(() => identities.id, { onDelete: "cascade", onUpdate: "cascade" } ), }); -export const facts = pgTable("facts", { - id: uuid("id").primaryKey().notNull(), - entity: uuid("entity").notNull().references(() => entities.id, { onDelete: "cascade", onUpdate: "restrict" } ), - attribute: text("attribute").notNull(), - data: jsonb("data").notNull(), - created_at: timestamp("created_at", { mode: 'string' }).defaultNow().notNull(), - updated_at: timestamp("updated_at", { mode: 'string' }), - // You can use { mode: "bigint" } if numbers are exceeding js number limitations - version: bigint("version", { mode: "number" }).default(0).notNull(), +export const permission_token_on_homepage = pgTable("permission_token_on_homepage", { + token: uuid("token").notNull().references(() => permission_tokens.id, { onDelete: "cascade" } ), + identity: uuid("identity").notNull().references(() => identities.id, { onDelete: "cascade" } ), + created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), +}, +(table) => { + return { + permission_token_creator_pkey: primaryKey({ columns: [table.token, table.identity], name: "permission_token_creator_pkey"}), + } }); export const permission_token_rights = pgTable("permission_token_rights", { diff --git a/src/replicache/index.tsx b/src/replicache/index.tsx index 72ef1e0e..6b60ee07 100644 --- a/src/replicache/index.tsx +++ b/src/replicache/index.tsx @@ -54,9 +54,11 @@ export function ReplicacheProvider(props: { token: PermissionToken; name: string; children: React.ReactNode; + initialFactsOnly?: boolean; }) { let [rep, setRep] = useState>(null); useEffect(() => { + if (props.initialFactsOnly) return; let supabase = supabaseBrowserClient(); let newRep = new Replicache({ pushDelay: 500, @@ -109,7 +111,7 @@ export function ReplicacheProvider(props: { setRep(null); channel.unsubscribe(); }; - }, [props.name]); + }, [props.name, props.initialFactsOnly, props.token]); return ( > 'value') = f.entity + ) + where + (f1.data ->> 'type' in ('reference', 'ordered-reference', 'spatial-reference')) + and f1.depth < max_depth -- Add depth limit parameter + ) +select + id, entity, attribute, data, created_at, updated_at, version +from + all_facts;$function$ +; + +CREATE OR REPLACE FUNCTION public.get_facts_for_roots(roots uuid[], max_depth integer) + RETURNS TABLE(root_id uuid, id uuid, entity uuid, attribute text, data jsonb, created_at timestamp without time zone, updated_at timestamp without time zone, version bigint) + LANGUAGE sql +AS $function$ + SELECT + root_id, + f.* + FROM unnest(roots) AS root_id + CROSS JOIN LATERAL get_facts_with_depth(root_id, max_depth) f; +$function$ +; \ No newline at end of file -- 2.51.2