diff --git a/api/routes.ts b/api/routes.ts index 7d5a5ef..407e71a 100644 --- a/api/routes.ts +++ b/api/routes.ts @@ -91,7 +91,7 @@ const defs = { authorize: withUserSession, fn: ({ session }) => session, output: UserDef, - description: 'Handle Google OAuth callback', + description: 'Get current authenticated user information', }), 'GET/api/picture': route({ fn: (_ctx, { hash }) => getPicture(hash), @@ -262,7 +262,7 @@ const defs = { }), 'GET/api/deployment': route({ authorize: withAdminSession, - fn: async (_ctx, url) => { + fn: async (_ctx, { url }) => { const dep = DeploymentsCollection.get(url) if (!dep) throw respond.NotFound() const { tokenSalt, ...deployment } = dep @@ -274,7 +274,7 @@ const defs = { token, } }, - input: STR(), + input: OBJ({ url: STR('Deployment URL') }), output: deploymentOutput, description: 'Get a deployment by ID', }), @@ -316,7 +316,7 @@ const defs = { output: deploymentOutput, description: 'Update a deployment by ID', }), - 'GET/api/deployment/token/regenerate': route({ + 'POST/api/deployment/token/regenerate': route({ authorize: withAdminSession, fn: async (_ctx, { url }) => { const dep = DeploymentsCollection.get(url) diff --git a/web/index.tsx b/web/index.tsx index c070327..eee60ee 100644 --- a/web/index.tsx +++ b/web/index.tsx @@ -26,7 +26,7 @@ const App = () => {
-
+
{renderPage()}
diff --git a/web/pages/DeploymentPage.tsx b/web/pages/DeploymentPage.tsx index b4f1b25..ab5c976 100644 --- a/web/pages/DeploymentPage.tsx +++ b/web/pages/DeploymentPage.tsx @@ -841,8 +841,8 @@ function TabNavigation({ const logData = api['POST/api/deployment/logs'].signal() effect(() => { - const { dep, lq } = url.params - if (dep) { + const { dep, lq, sbi } = url.params + if (dep && sbi === 'deployment') { const filterRows = parseFilters('l').filter((r) => r.key !== 'key' && r.value ).map((r) => ({ diff --git a/web/pages/ProjectPage.tsx b/web/pages/ProjectPage.tsx index 95749d8..d437846 100644 --- a/web/pages/ProjectPage.tsx +++ b/web/pages/ProjectPage.tsx @@ -2,7 +2,7 @@ import { effect } from '@preact/signals' import { navigate, url } from '@01edu/signal-router' import { Sidebar } from '../components/SideBar.tsx' import { user } from '../lib/session.ts' -import { SettingsPage } from './project/SettingsPage.tsx' +import { SettingsPage } from './SettingsPage.tsx' import { deployments, project, sidebarItems } from '../lib/shared.tsx' effect(() => { @@ -29,9 +29,9 @@ export function ProjectPage() { } return ( -
+
-
+
{ + if (url.params.dep) { + getDeployment.fetch({ url: url.params.dep }) + } +}) + +const navItems = [ + { id: 'project', label: 'Project', icon: Settings }, + { id: 'deployments', label: 'Deployments', icon: Cloud }, + { id: 'team', label: 'Team Members', icon: Users }, +] +const formatDate = (d?: number | null) => + d ? new Date(d).toLocaleDateString() : undefined +const isEditing = (key: string) => url.params.editing === key + +const rowClass = + 'flex justify-between items-center py-2 border-b border-base-300 last:border-0' +const inputXs = + 'input input-xs input-bordered w-full text-[11px] bg-base-100/50 h-7' +const btnXs = 'btn btn-xs btn-square join-item h-7 min-h-0' + +const SettingsSidebar = () => ( + +) + +const Layout = ({ + title, + desc, + actions, + error, + children, +}: { + title: string + desc: string + actions?: preact.ComponentChildren + error?: string | null + children: preact.ComponentChildren +}) => ( +
+
+
+

{title}

+

{desc}

+
+ {actions} +
+
+
+ {error && ( +
+ + {error} +
+ )} + {children} +
+
+
+) + +const Card = ( + { title, action, children }: { + title: string + action?: preact.ComponentChildren + children: preact.ComponentChildren + }, +) => ( +
+
+

{title}

+ {action} +
+
{children}
+
+) + +const EditCard = ( + { title, editKey, saving, children }: { + title: string + editKey: string + saving?: boolean + children: preact.ComponentChildren + }, +) => ( +
+
+

{title}

+ {isEditing(editKey) + ? ( +
+ + + + +
+ ) + : ( + + Edit + + )} +
+
{children}
+
+) + +const Row = ( + { label, value }: { label: string; value?: string | number | null }, +) => ( +
+ {label} + {value ?? '–'} +
+) + +const TextRow = ( + { label, name, value, editKey }: { + label: string + name: string + value: string + editKey: string + }, +) => ( +
+ {label} + {isEditing(editKey) + ? ( + + ) + : {value || '–'}} +
+) + +const ToggleRow = ( + { label, name, value, editKey }: { + label: string + name?: string + value: boolean + editKey: string + }, +) => ( +
+ {label} + {isEditing(editKey) + ? ( + + ) + : {value ? 'Yes' : 'No'}} +
+) + +const Label = ({ text, desc }: { text: string; desc: string }) => ( +
+ {text} + • {desc} +
+) + +const Input = ( + { name, value, editKey, placeholder, secret, visKey, mono }: { + name: string + value: string + editKey: string + placeholder?: string + secret?: boolean + visKey?: string + mono?: boolean + }, +) => { + const visible = visKey ? url.params[visKey] === 'true' : !secret + return ( +
+ + {secret && visKey && ( + + {visible ? : } + + )} +
+ ) +} + +const Accordion = ({ + label, + name, + value, + editKey, + urlKey, + hideOnEdit, + children, +}: { + label: string + name: string + value: boolean + editKey: string + urlKey: string + hideOnEdit?: boolean + children: preact.ComponentChildren +}) => { + const param = url.params[urlKey] + const enabled = param != null ? param === 'true' : value + const editing = isEditing(editKey) + const showChildren = enabled && !(hideOnEdit && editing) + const onChange = (v: boolean) => + navigate({ params: { [urlKey]: String(v) }, replace: true }) + return ( +
+
+ {label} + {editing + ? ( + onChange(e.currentTarget.checked)} + class='toggle toggle-sm toggle-primary' + /> + ) + : {enabled ? 'Yes' : 'No'}} +
+ {showChildren && ( +
+ {children} +
+ )} +
+ ) +} + +const ToolCard = ( + { title, desc, empty }: { title: string; desc: string; empty: string }, +) => ( +
+

{title}

+

{desc}

+
{empty}
+ + Add {title.split(' ').pop()} + +
+) + +const addDeploymentError = signal(null) +const handleSubmit = async (e: TargetedEvent) => { + e.preventDefault() + const fd = new FormData(e.currentTarget) + const projectId = project.data?.slug + if (!projectId) return + try { + const depUrl = new URL(fd.get('url') as string).host + await createDeployment.fetch({ + url: depUrl, + projectId, + logsEnabled: false, + databaseEnabled: false, + sqlEndpoint: null, + sqlToken: null, + }) + deployments.fetch({ project: projectId }) + navigate({ + params: { dialog: null }, + replace: true, + }) + } catch (e) { + addDeploymentError.value = e instanceof Error ? e.message : 'Unknown error' + } +} +const AddDeploymentDialog = () => ( + +

Add Deployment

+
+
+ + +
+ {(createDeployment.error || addDeploymentError.value) && ( +
+ {createDeployment.error || addDeploymentError.value} +
+ )} + +
+
+) + +function LogsTokenSection({ deploymentUrl }: { deploymentUrl: string }) { + const visible = url.params['token-vis'] === 'true' + const loading = getDeployment.pending || regenToken.pending + + const regenerate = async () => { + if (!confirm('Regenerate token?')) return + await regenToken.fetch({ url: deploymentUrl }) + getDeployment.fetch({ url: deploymentUrl }) + } + + return ( +
+
+ ) +} + +// Pages +const handleProjectSubmit = async (e: TargetedEvent) => { + e.preventDefault() + if (!project.data) return + const fd = new FormData(e.currentTarget) + try { + await updateProject.fetch({ + ...project.data, + name: fd.get('name') as string, + repositoryUrl: (fd.get('repositoryUrl') as string) || undefined, + isPublic: fd.get('isPublic') === 'on', + }) + project.fetch({ slug: project.data.slug }) + navigate({ params: { editing: null }, replace: true }) + } catch (e) { + console.error(e) + } +} +const ProjectSettingsPage = () => { + const p = project.data, deps = deployments.data ?? [] + return ( + +
+ + + + + + + + +
+ + + + + Add + + } + > + {deps.length === 0 + ? ( +

+ No deployments configured. +

+ ) + : ( + + )} +
+
+ ) +} + +const handleDeploymentSubmit = async (e: TargetedEvent) => { + e.preventDefault() + const dep = getDeployment.data + if (!dep) return + const { logs, db } = url.params + const logsEnabled = logs != null + ? logs === 'true' + : (dep?.logsEnabled ?? false) + const databaseEnabled = db != null + ? db === 'true' + : (dep?.databaseEnabled ?? false) + const fd = new FormData(e.currentTarget) + try { + await updateDeployment.fetch({ + url: dep.url, + projectId: project.data!.slug, + logsEnabled, + databaseEnabled, + sqlEndpoint: (fd.get('sql-endpoint') as string) || undefined, + sqlToken: (fd.get('sql-token') as string) || undefined, + }) + getDeployment.fetch({ url: dep.url }) + navigate({ + params: { editing: null, logs: null, db: null }, + replace: true, + }) + } catch (e) { + console.error(e) + } +} +const DeploymentsSettingsPage = () => { + const deps = deployments.data ?? [], + dep = getDeployment.data, + selectedUrl = url.params.dep + + if (!selectedUrl && deps.length > 0) { + navigate({ params: { dep: deps[0].url }, replace: true }) + return null + } + + return ( + + + New + + {deps.length > 0 && ( + + )} +
+ } + > + {dep && ( +
+ +
+ + + + + +
+
+
+ + +
+
+
+ )} + {dep && ( + +
+ + +
+
+ )} + {!dep && deps.length > 0 && ( +
+ Select a deployment to view its configuration. +
+ )} + {getDeployment.pending && ( +
+ Loading deployment... +
+ )} + + ) +} + +const TeamSettingsPage = () => ( + +
+ Team settings content will go here. +
+
+) + +const views = { + project: ProjectSettingsPage, + deployments: DeploymentsSettingsPage, + team: TeamSettingsPage, +} as const + +export const SettingsPage = () => { + const { view = 'project' } = url.params + if (!project.data) { + return ( +
+ +
+ ) + } + const Content = views[view as keyof typeof views] ?? views.project + return ( +
+ +
+ +
+ {project.data && } +
+ ) +} diff --git a/web/pages/project/SettingsPage.tsx b/web/pages/project/SettingsPage.tsx deleted file mode 100644 index 6c414a4..0000000 --- a/web/pages/project/SettingsPage.tsx +++ /dev/null @@ -1,297 +0,0 @@ -import { useSignal } from '@preact/signals' -import { navigate, url } from '@01edu/signal-router' -import type { TargetedEvent } from 'preact' -import { PageContent, PageHeader } from '../../components/Layout.tsx' -import { Button, Card, Input, Switch } from '../../components/forms.tsx' -import { api, ApiOutput } from '../../lib/api.ts' -import { user } from '../../lib/session.ts' -import { deployments, project } from '../../lib/shared.tsx' - -type Project = ApiOutput['GET/api/projects'][number] -type Deployment = ApiOutput['GET/api/project/deployments'][number] -type User = ApiOutput['GET/api/users'][number] - -const users = api['GET/api/users'].signal() -users.fetch() - -const team = api['GET/api/team'].signal() - -function ProjectInfoForm() { - const handleSubmit = (e: TargetedEvent) => { - e.preventDefault() - } - - return ( - -
-
- - -
-
- -
-
-
- ) -} - -function DeploymentForm({ deployment }: { deployment?: Deployment }) { - const databaseEnabled = useSignal(deployment?.databaseEnabled || false) - const handleSubmit = (e: TargetedEvent) => { - e.preventDefault() - } - - return ( - -
-
- - - databaseEnabled.value = e.currentTarget.checked} - note='Provide an endpoint to execute SQL queries against your database.' - /> - {databaseEnabled.value && ( -
- - -
- )} -
-
- - -
-
-
- ) -} - -function DeploymentsList({ deployments }: { deployments: Deployment[] }) { - const handleDelete = (_id: string) => { - } - - return ( - -
- {deployments.map((dep) => ( -
-
-

{dep.url}

-

- Logs: {dep.logsEnabled ? 'Enabled' : 'Disabled'} | Database: - {' '} - {dep.databaseEnabled ? 'Enabled' : 'Disabled'} -

-
-
- - -
-
- ))} -
-
- -
-
- ) -} - -function UserManagement() { - const teamMembersDetails = team.data?.teamMembers.map((email) => - users.data?.find((u) => u.userEmail === email) - ).filter(Boolean) as User[] - - const handleAddUser = (e: TargetedEvent) => { - e.preventDefault() - } - - const handleRemoveUser = (_email: string) => { - } - - return ( - -
- {teamMembersDetails.map((member) => ( -
-
- {member.userFullName} -
-

{member.userFullName}

-

- {member.userEmail} -

-
-
- -
- ))} -
-
-

Add a new user

-
- -
- -
-
-
-
- ) -} - -export const SettingsPage = () => { - if (!user.data?.isAdmin) { - navigate({ params: { nav: 'deployments' } }) - } - const { view = 'info', action, id } = url.params - if (!project.data) return null - - team.fetch({ teamId: project.data!.teamId }) - - const content = view === 'deployments' - ? ( - action === 'add' - ? - : action === 'edit' && id - ? ( - d.url === id)} - /> - ) - : - ) - : view === 'users' - ? - : - - return ( - <> - -

- Project Settings: {project.data?.name} -

-
- - - -
-
- -
- {content} -
-
- - ) -}