From 097fbfd4c9aa199de6385991b2bf87f9e9de5073 Mon Sep 17 00:00:00 2001
From: Arjun Komath <arjun@hey.com>
Date: Thu, 9 Jan 2025 18:15:43 +1100
Subject: [PATCH] Logto API integration

---
 .../calendar/[ownerId]/[projectId]/route.ts   |  7 +-
 app/(dashboard)/[tenant]/layout.tsx           |  8 +--
 app/(dashboard)/[tenant]/settings/actions.ts  |  5 ++
 components/console/navbar.tsx                 |  3 +-
 components/core/auth.tsx                      | 14 ++--
 lib/ops/auth.ts                               | 71 +++++++++++--------
 lib/utils/useOwner.ts                         |  8 ++-
 package.json                                  |  1 +
 pnpm-lock.yaml                                | 14 +++-
 9 files changed, 80 insertions(+), 51 deletions(-)

diff --git a/app/(api)/api/calendar/[ownerId]/[projectId]/route.ts b/app/(api)/api/calendar/[ownerId]/[projectId]/route.ts
index 17a4eb1..b609293 100644
--- a/app/(api)/api/calendar/[ownerId]/[projectId]/route.ts
+++ b/app/(api)/api/calendar/[ownerId]/[projectId]/route.ts
@@ -1,5 +1,6 @@
 import { calendarEvent, project, task, taskList } from "@/drizzle/schema";
 import { getDatabaseForOwner } from "@/lib/utils/useDatabase";
+import { getVtimezoneComponent } from "@touch4it/ical-timezones";
 import { and, desc, eq, lte } from "drizzle-orm";
 import ical, { ICalCalendarMethod } from "ical-generator";
 
@@ -46,7 +47,7 @@ export async function GET(
 	const calendar = ical({
 		name: projectDetails.name,
 		method: ICalCalendarMethod.PUBLISH,
-		timezone: { name: "UTC" },
+		timezone: { name: "Australia/Sydney", generator: getVtimezoneComponent },
 	});
 
 	for (const event of events) {
@@ -60,7 +61,7 @@ export async function GET(
 			created: event.createdAt,
 			lastModified: event.updatedAt,
 			repeating: event.repeatRule,
-			timezone: "UTC",
+			timezone: "Australia/Sydney",
 		});
 	}
 
@@ -82,7 +83,7 @@ export async function GET(
 				allDay: true,
 				created: task.createdAt,
 				lastModified: task.updatedAt,
-				timezone: "UTC",
+				timezone: "Australia/Sydney",
 			});
 		}
 	}
diff --git a/app/(dashboard)/[tenant]/layout.tsx b/app/(dashboard)/[tenant]/layout.tsx
index 767dd21..3dc41a4 100644
--- a/app/(dashboard)/[tenant]/layout.tsx
+++ b/app/(dashboard)/[tenant]/layout.tsx
@@ -1,7 +1,7 @@
 import NavBar from "@/components/console/navbar";
 import { ReportTimezone } from "@/components/core/report-timezone";
 import { isDatabaseReady } from "@/lib/utils/useDatabase";
-import { getOrgs, getOwner } from "@/lib/utils/useOwner";
+import { getOrganizations, getOwner } from "@/lib/utils/useOwner";
 import { redirect } from "next/navigation";
 
 export const fetchCache = "force-no-store"; // disable cache for console pages
@@ -19,8 +19,8 @@ export default async function ConsoleLayout(props: {
 
 	const ready = await isDatabaseReady();
 	const { orgId, orgSlug } = await getOwner();
-	const orgs = await getOrgs();
-	const activatedOrg = orgs.find((org) => org.id === orgId) ?? null;
+	const organizations = await getOrganizations();
+	const activatedOrg = organizations.find((org) => org.id === orgId) ?? null;
 
 	if (!ready || params.tenant !== orgSlug) {
 		redirect("/start");
@@ -28,7 +28,7 @@ export default async function ConsoleLayout(props: {
 
 	return (
 		<div className="relative flex min-h-full flex-col">
-			<NavBar orgs={orgs} activeOrg={activatedOrg} />
+			<NavBar orgs={organizations} activeOrg={activatedOrg} />
 
 			<div className="mx-auto w-full flex-grow lg:flex">
 				<div className="min-w-0 flex-1 xl:flex">
diff --git a/app/(dashboard)/[tenant]/settings/actions.ts b/app/(dashboard)/[tenant]/settings/actions.ts
index bc1e69b..1448a3f 100644
--- a/app/(dashboard)/[tenant]/settings/actions.ts
+++ b/app/(dashboard)/[tenant]/settings/actions.ts
@@ -1,5 +1,7 @@
 "use server";
 
+import { updateUser } from "@/lib/ops/auth";
+import { getOwner } from "@/lib/utils/useOwner";
 import { cookies } from "next/headers";
 
 export async function saveUserTimezone(timezone: string) {
@@ -9,4 +11,7 @@ export async function saveUserTimezone(timezone: string) {
 		sameSite: "strict",
 		maxAge: 60 * 60 * 24 * 365,
 	});
+
+	const { userId } = await getOwner();
+	await updateUser(userId, { customData: { timezone } });
 }
diff --git a/components/console/navbar.tsx b/components/console/navbar.tsx
index 7387f52..9dbd579 100644
--- a/components/console/navbar.tsx
+++ b/components/console/navbar.tsx
@@ -1,7 +1,8 @@
+import type { Organization } from "@/lib/ops/auth";
 import Image from "next/image";
 import Link from "next/link";
 import logo from "../../public/images/logo.png";
-import { OrgSwitcher, type Organization, UserButton } from "../core/auth";
+import { OrgSwitcher, UserButton } from "../core/auth";
 import NavBarLinks from "./navbar-links";
 
 export default function NavBar({
diff --git a/components/core/auth.tsx b/components/core/auth.tsx
index d70c84d..e4618d8 100644
--- a/components/core/auth.tsx
+++ b/components/core/auth.tsx
@@ -1,4 +1,5 @@
 import { logtoConfig } from "@/app/logto";
+import type { Organization } from "@/lib/ops/auth";
 import { signOut } from "@logto/next/server-actions";
 import { ChevronsUpDown, Plus, User } from "lucide-react";
 import Link from "next/link";
@@ -12,13 +13,6 @@ import {
 	DropdownMenuTrigger,
 } from "../ui/dropdown-menu";
 
-// WIP, this should be changed
-export type Organization = {
-	id: string;
-	name: string;
-	slug: string;
-};
-
 export const OrgSwitcher = ({
 	orgs,
 	activeOrg,
@@ -67,7 +61,11 @@ export const OrgSwitcher = ({
 							// }
 						>
 							<input type="hidden" name="id" value={org.id} />
-							<input type="hidden" name="slug" value={org.slug} />
+							<input
+								type="hidden"
+								name="slug"
+								value={String(org.customData?.slug)}
+							/>
 							<button
 								type="submit"
 								className="flex w-full"
diff --git a/lib/ops/auth.ts b/lib/ops/auth.ts
index 56cb59f..17efc2d 100644
--- a/lib/ops/auth.ts
+++ b/lib/ops/auth.ts
@@ -4,6 +4,31 @@ const applicationId = process.env.LOGTO_M2M_APP_ID!;
 const applicationSecret = process.env.LOGTO_M2M_APP_SECRET!;
 const tenantId = "default";
 
+/**
+ * Docs
+ * https://openapi.logto.io
+ */
+
+export interface Organization {
+	tenantId: string;
+	id: string;
+	name: string;
+	description: string;
+	customData: Record<string, string | number | boolean>;
+	isMfaRequired: boolean;
+	branding: {
+		logoUrl: string;
+		darkLogoUrl: string;
+		favicon: string;
+		darkFavicon: string;
+	};
+	createdAt: number;
+	organizationRoles: {
+		id: string;
+		name: string;
+	}[];
+}
+
 export const fetchAccessToken = async () => {
 	const { endpoint } = logtoConfig;
 	return await fetch(`${endpoint}oidc/token`, {
@@ -21,10 +46,13 @@ export const fetchAccessToken = async () => {
 		}).toString(),
 	});
 };
-
-export const createOrganizationForUser = async (
+export const updateUser = async (
 	userId: string,
-	name: string,
+	data: {
+		name?: string;
+		primaryEmail?: string;
+		customData?: Record<string, unknown>;
+	},
 ) => {
 	const { access_token } = await fetchAccessToken().then((res) => res.json());
 	if (!access_token) {
@@ -32,48 +60,33 @@ export const createOrganizationForUser = async (
 	}
 
 	const { endpoint } = logtoConfig;
-	const response = await fetch(`${endpoint}api/organizations`, {
-		method: "POST",
+	const response = await fetch(`${endpoint}api/users/${userId}`, {
+		method: "PATCH",
 		headers: {
 			Authorization: `Bearer ${access_token}`,
 			"Content-Type": "application/json",
 		},
-		body: JSON.stringify({
-			name,
-		}),
+		body: JSON.stringify(data),
 	});
 
 	if (!response.ok) {
-		console.error("Failed to create organization", response.status);
-		throw new Error("Failed to create organization");
+		console.error("Failed to update user", response.status);
+		throw new Error("Failed to update user");
 	}
 
-	const organization = await response.json();
-	console.log("organization", organization);
-
-	// Add user to organization
-	await fetch(`${endpoint}api/organizations/${organization.id}/users`, {
-		method: "POST",
-		headers: {
-			Authorization: `Bearer ${access_token}`,
-			"Content-Type": "application/json",
-		},
-		body: JSON.stringify({
-			userIds: [userId],
-		}),
-	});
-
-	return organization;
+	return await response.json();
 };
 
-export const getOrganizationsForUser = async (userId: string) => {
+export const getOrganizationsForUser = async (
+	userId: string,
+): Promise<Organization[]> => {
 	const { access_token } = await fetchAccessToken().then((res) => res.json());
 	if (!access_token) {
 		throw new Error("Access token not found");
 	}
 
 	const { endpoint } = logtoConfig;
-	const response = await fetch(`${endpoint}api/organizations`, {
+	const response = await fetch(`${endpoint}api/users/${userId}/organizations`, {
 		method: "GET",
 		headers: {
 			Authorization: `Bearer ${access_token}`,
@@ -85,9 +98,7 @@ export const getOrganizationsForUser = async (userId: string) => {
 		console.error("Failed to fetch organizations", response.status);
 		throw new Error("Failed to fetch organizations");
 	}
-
 	const organizations = await response.json();
-	console.log("organizations", organizations);
 
 	return organizations;
 };
diff --git a/lib/utils/useOwner.ts b/lib/utils/useOwner.ts
index f5892fe..7c54322 100644
--- a/lib/utils/useOwner.ts
+++ b/lib/utils/useOwner.ts
@@ -1,5 +1,4 @@
 import { logtoConfig } from "@/app/logto";
-import type { Organization } from "@/components/core/auth";
 import { user } from "@/drizzle/schema";
 import type { User } from "@/drizzle/types";
 import { getLogtoContext } from "@logto/next/server-actions";
@@ -8,6 +7,7 @@ import timezone from "dayjs/plugin/timezone";
 import utc from "dayjs/plugin/utc";
 import { eq } from "drizzle-orm";
 import { cookies } from "next/headers";
+import { type Organization, getOrganizationsForUser } from "../ops/auth";
 import { database } from "./useDatabase";
 
 dayjs.extend(utc);
@@ -39,8 +39,10 @@ export async function getUser(): Promise<User> {
 	return userDetails;
 }
 
-export async function getOrgs(): Promise<Organization[]> {
-	return [];
+export async function getOrganizations(): Promise<Organization[]> {
+	const { userId } = await getOwner();
+	const organizations = await getOrganizationsForUser(userId);
+	return organizations;
 }
 
 export async function getOwner(): Promise<Result> {
diff --git a/package.json b/package.json
index 12303b5..343856d 100644
--- a/package.json
+++ b/package.json
@@ -31,6 +31,7 @@
 		"@radix-ui/react-separator": "^1.1.0",
 		"@radix-ui/react-slot": "^1.1.0",
 		"@radix-ui/react-switch": "^1.1.0",
+		"@touch4it/ical-timezones": "^1.9.0",
 		"autoprefixer": "10.4.14",
 		"better-sqlite3": "^11.7.0",
 		"class-variance-authority": "^0.6.1",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index ec78671..01a7ddc 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -69,6 +69,9 @@ importers:
       '@radix-ui/react-switch':
         specifier: ^1.1.0
         version: 1.1.0(@types/react-dom@19.0.2(@types/react@19.0.1))(@types/react@19.0.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
+      '@touch4it/ical-timezones':
+        specifier: ^1.9.0
+        version: 1.9.0
       autoprefixer:
         specifier: 10.4.14
         version: 10.4.14(postcss@8.4.23)
@@ -101,7 +104,7 @@ importers:
         version: 15.1.0(eslint@9.13.0(jiti@1.21.6))(typescript@5.7.2)
       ical-generator:
         specifier: ^8.0.0
-        version: 8.0.0(@types/node@20.1.0)(dayjs@1.11.12)(rrule@2.8.1)
+        version: 8.0.0(@touch4it/ical-timezones@1.9.0)(@types/node@20.1.0)(dayjs@1.11.12)(rrule@2.8.1)
       lucide-react:
         specifier: ^0.244.0
         version: 0.244.0(react@19.0.0)
@@ -2150,6 +2153,10 @@ packages:
   '@tanstack/virtual-core@3.8.4':
     resolution: {integrity: sha512-iO5Ujgw3O1yIxWDe9FgUPNkGjyT657b1WNX52u+Wv1DyBFEpdCdGkuVaky0M3hHFqNWjAmHWTn4wgj9rTr7ZQg==}
 
+  '@touch4it/ical-timezones@1.9.0':
+    resolution: {integrity: sha512-UAiZMrFlgMdOIaJDPsKu5S7OecyMLr3GGALJTYkRgHmsHAA/8Ixm1qD09ELP2X7U1lqgrctEgvKj9GzMbczC+g==}
+    engines: {node: '>= 14.0.0'}
+
   '@types/better-sqlite3@7.6.12':
     resolution: {integrity: sha512-fnQmj8lELIj7BSrZQAdBMHEHX8OZLYIHXqAKT1O7tDfLxaINzf00PMjw22r3N/xXh0w/sGHlO6SVaCQ2mj78lg==}
 
@@ -6845,6 +6852,8 @@ snapshots:
 
   '@tanstack/virtual-core@3.8.4': {}
 
+  '@touch4it/ical-timezones@1.9.0': {}
+
   '@types/better-sqlite3@7.6.12':
     dependencies:
       '@types/node': 20.1.0
@@ -8057,10 +8066,11 @@ snapshots:
 
   html-url-attributes@3.0.1: {}
 
-  ical-generator@8.0.0(@types/node@20.1.0)(dayjs@1.11.12)(rrule@2.8.1):
+  ical-generator@8.0.0(@touch4it/ical-timezones@1.9.0)(@types/node@20.1.0)(dayjs@1.11.12)(rrule@2.8.1):
     dependencies:
       uuid-random: 1.3.2
     optionalDependencies:
+      '@touch4it/ical-timezones': 1.9.0
       '@types/node': 20.1.0
       dayjs: 1.11.12
       rrule: 2.8.1