Init server features

This commit is contained in:
Hakan Shehu
2024-09-26 18:38:19 +02:00
parent aecca3e360
commit 1fb0975b09
11 changed files with 310 additions and 75 deletions

View File

@@ -14,9 +14,9 @@ import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { LoginOutput } from '@/types/accounts';
import { toast } from '@/components/ui/use-toast';
import {parseApiError} from "@/lib/axios";
import {Icon} from "@/components/ui/icon";
import axios from "axios";
import { parseApiError } from '@/lib/axios';
import { Icon } from '@/components/ui/icon';
import axios from 'axios';
const formSchema = z.object({
name: z.string().min(2),
@@ -29,7 +29,10 @@ interface EmailRegisterProps {
onRegister: (output: LoginOutput) => void;
}
export const EmailRegister = ({ serverUrl, onRegister }: EmailRegisterProps) => {
export const EmailRegister = ({
serverUrl,
onRegister,
}: EmailRegisterProps) => {
const [isPending, setIsPending] = React.useState(false);
const form = useForm<z.infer<typeof formSchema>>({
@@ -46,7 +49,7 @@ export const EmailRegister = ({ serverUrl, onRegister }: EmailRegisterProps) =>
try {
const { data } = await axios.post<LoginOutput>(
`${serverUrl}/v1/accounts/register/email`,
values
values,
);
onRegister(data);
@@ -60,7 +63,7 @@ export const EmailRegister = ({ serverUrl, onRegister }: EmailRegisterProps) =>
} finally {
setIsPending(false);
}
}
};
return (
<Form {...form}>
@@ -117,4 +120,4 @@ export const EmailRegister = ({ serverUrl, onRegister }: EmailRegisterProps) =>
</form>
</Form>
);
}
};

View File

@@ -0,0 +1,121 @@
import React from 'react';
import { useAppDatabase } from '@/contexts/app-database';
import { LoginOutput } from '@/types/accounts';
import { Server } from '@/types/servers';
import { EmailRegister } from '@/components/accounts/email-register';
import { EmailLogin } from '@/components/accounts/email-login';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Icon } from '@/components/ui/icon';
import { Avatar } from '@/components/ui/avatar';
interface LoginFormProps {
servers: Server[];
}
export const LoginForm = ({ servers }: LoginFormProps) => {
const appDatabase = useAppDatabase();
const [showRegister, setShowRegister] = React.useState(false);
const [server, setServer] = React.useState(servers[0]);
const serverUrl = `https://${server.domain}`;
const handleLogin = async (output: LoginOutput) => {
const insertAccountQuery = appDatabase.database
.insertInto('accounts')
.values({
id: output.account.id,
name: output.account.name,
avatar: output.account.avatar,
device_id: output.account.deviceId,
email: output.account.email,
token: output.account.token,
})
.compile();
await appDatabase.mutate(insertAccountQuery);
if (output.workspaces.length > 0) {
const insertWorkspacesQuery = appDatabase.database
.insertInto('workspaces')
.values(
output.workspaces.map((workspace) => ({
id: workspace.id,
name: workspace.name,
account_id: output.account.id,
avatar: workspace.avatar,
role: workspace.role,
description: workspace.description,
synced: 0,
user_id: workspace.userId,
version_id: workspace.versionId,
})),
)
.compile();
await appDatabase.mutate(insertWorkspacesQuery);
}
window.location.href = '/';
};
return (
<div className="flex flex-col gap-4">
<div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<div className="flex w-full flex-grow flex-row items-center gap-2 rounded-md border border-input p-2 hover:cursor-pointer hover:bg-gray-100">
<Avatar id={server.domain} name={server.name} />
<div className="flex-grow">
<p className="flex-grow font-semibold">{server.name}</p>
<p className="text-xs text-muted-foreground">{server.domain}</p>
</div>
<Icon
name="arrow-down-s-line"
className="h-4 w-4 text-muted-foreground"
/>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-96">
{servers.map((server) => (
<DropdownMenuItem
key={server.domain}
onSelect={() => {
setServer(server);
}}
className="flex w-full flex-grow flex-row items-center gap-2 rounded-md border-b border-input p-2 hover:cursor-pointer hover:bg-gray-100"
>
<Avatar id={server.domain} name={server.name} />
<div className="flex-grow">
<p className="flex-grow font-semibold">{server.name}</p>
<p className="text-xs text-muted-foreground">
{server.domain}
</p>
</div>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
{showRegister ? (
<EmailRegister serverUrl={serverUrl} onRegister={handleLogin} />
) : (
<EmailLogin serverUrl={serverUrl} onLogin={handleLogin} />
)}
<p
className="text-center text-sm text-muted-foreground hover:cursor-pointer hover:underline"
onClick={() => {
setShowRegister(!showRegister);
}}
>
{showRegister
? 'Already have an account? Login'
: 'No account yet? Register'}
</p>
</div>
);
};

View File

@@ -1,86 +1,26 @@
import React from 'react';
import { EmailLogin } from '@/components/accounts/email-login';
import { LoginOutput } from '@/types/accounts';
import { EmailRegister } from '@/components/accounts/email-register';
import { useAppDatabase } from '@/contexts/app-database';
const SERVER_URL = 'http://localhost:3000';
import { LoginForm } from './login-form';
import { useServersQuery } from '@/queries/use-servers-query';
export const Login = () => {
const appDatabase = useAppDatabase();
const [showRegister, setShowRegister] = React.useState(false);
const handleLogin = async (output: LoginOutput) => {
const insertAccountQuery = appDatabase.database
.insertInto('accounts')
.values({
id: output.account.id,
name: output.account.name,
avatar: output.account.avatar,
device_id: output.account.deviceId,
email: output.account.email,
token: output.account.token,
})
.compile();
await appDatabase.mutate(insertAccountQuery);
if (output.workspaces.length > 0) {
const insertWorkspacesQuery = appDatabase.database
.insertInto('workspaces')
.values(
output.workspaces.map((workspace) => ({
id: workspace.id,
name: workspace.name,
account_id: output.account.id,
avatar: workspace.avatar,
role: workspace.role,
description: workspace.description,
synced: 0,
user_id: workspace.userId,
version_id: workspace.versionId,
})),
)
.compile();
await appDatabase.mutate(insertWorkspacesQuery);
}
window.location.href = '/';
};
const { data, isPending } = useServersQuery();
return (
<div className="grid h-screen min-h-screen w-full grid-cols-5">
<div className="col-span-2 flex items-center justify-center bg-zinc-950">
<h1 className="font-neotrax text-6xl text-white">neuron</h1>
<h1 className="font-neotrax text-6xl text-white">colabron</h1>
</div>
<div className="col-span-3 flex items-center justify-center py-12">
<div className="mx-auto grid w-96 gap-6">
<div className="grid gap-2 text-center">
<h1 className="text-2xl font-semibold tracking-tight">
Login to Neuron
Login to Colabron
</h1>
<p className="text-sm text-muted-foreground">
Use one of the following methods to login
</p>
</div>
<div className="flex flex-col gap-4">
{showRegister ? (
<EmailRegister serverUrl={SERVER_URL} onRegister={handleLogin} />
) : (
<EmailLogin serverUrl={SERVER_URL} onLogin={handleLogin} />
)}
<p
className="text-center text-sm text-muted-foreground hover:cursor-pointer hover:underline"
onClick={() => {
setShowRegister(!showRegister);
}}
>
{showRegister
? 'Already have an account? Login'
: 'No account yet? Register'}
</p>
</div>
{isPending ? null : <LoginForm servers={data} />}
</div>
</div>
</div>

View File

@@ -116,6 +116,7 @@ export const App = () => {
email: account.email,
deviceId: account.device_id,
status: account.status,
server: account.server,
workspaces: workspacesQuery.data?.rows.map((row) => {
return {
id: row.id,

View File

@@ -8,6 +8,7 @@ import {
SqliteDialect,
} from 'kysely';
import { AccountManager } from '@/data/account-manager';
import { ServerManager } from '@/data/server-manager';
import { AppDatabaseSchema } from '@/data/schemas/app';
import { appDatabaseMigrations } from '@/data/migrations/app';
import { Account } from '@/types/accounts';
@@ -20,10 +21,12 @@ import { Workspace } from '@/types/workspaces';
import { SubscribedQueryContext, SubscribedQueryResult } from '@/types/queries';
import { eventBus } from '@/lib/event-bus';
import { isEqual } from 'lodash';
import { Server } from '@/types/servers';
const EVENT_LOOP_INTERVAL = 1000;
class AppManager {
private readonly servers: Map<string, ServerManager>;
private readonly accounts: Map<string, AccountManager>;
private readonly appPath: string;
private readonly database: Kysely<AppDatabaseSchema>;
@@ -209,6 +212,12 @@ class AppManager {
private async executeInit(): Promise<void> {
await this.migrate();
const servers = await this.getServers();
for (const server of servers) {
const serverManager = new ServerManager(server);
this.servers.set(server.domain, serverManager);
}
const accounts = await this.getAccounts();
const workspaces = await this.getWorkspaces();
for (const account of accounts) {
@@ -265,6 +274,25 @@ class AppManager {
await migrator.migrateToLatest();
}
private async getServers(): Promise<Server[]> {
const servers = await this.database
.selectFrom('servers')
.selectAll()
.execute();
return servers.map((server) => ({
domain: server.domain,
name: server.name,
avatar: server.avatar,
attributes: JSON.parse(server.attributes),
version: server.version,
createdAt: new Date(server.created_at),
lastSyncedAt: server.last_synced_at
? new Date(server.last_synced_at)
: null,
}));
}
private async getAccounts(): Promise<Account[]> {
const accounts = await this.database
.selectFrom('accounts')
@@ -278,6 +306,7 @@ class AppManager {
avatar: account.avatar,
token: account.token,
deviceId: account.device_id,
server: account.server,
status: account.status,
}));
}

View File

@@ -1,11 +1,70 @@
import { Migration } from 'kysely';
const createServersTable: Migration = {
up: async (db) => {
await db.schema
.createTable('servers')
.addColumn('domain', 'text', (col) => col.notNull().primaryKey())
.addColumn('name', 'text', (col) => col.notNull())
.addColumn('avatar', 'text', (col) => col.notNull())
.addColumn('attributes', 'text', (col) => col.notNull())
.addColumn('version', 'text', (col) => col.notNull())
.addColumn('created_at', 'text', (col) => col.notNull())
.addColumn('last_synced_at', 'text')
.execute();
},
down: async (db) => {
await db.schema.dropTable('servers').execute();
},
};
const addNeuronServers: Migration = {
up: async (db) => {
await db
.insertInto('servers')
.values([
{
domain: 'localhost:3000',
name: 'Localhost',
avatar: '',
attributes: '{}',
version: '0.1.0',
created_at: new Date().toISOString(),
},
{
domain: 'eu.neuronapp.io',
name: 'Neuron Cloud (EU)',
avatar: '',
attributes: '{}',
version: '0.1.0',
created_at: new Date().toISOString(),
},
{
domain: 'us.neuronapp.io',
name: 'Neuron Cloud (US)',
avatar: '',
attributes: '{}',
version: '0.1.0',
created_at: new Date().toISOString(),
},
])
.execute();
},
down: async (db) => {
await db
.deleteFrom('servers')
.where('id', 'in', ['localhost', 'eu.neuronapp.io', 'us.neuronapp.io'])
.execute();
},
};
const createAccountsTable: Migration = {
up: async (db) => {
await db.schema
.createTable('accounts')
.addColumn('id', 'text', (col) => col.notNull().primaryKey())
.addColumn('device_id', 'text', (col) => col.notNull())
.addColumn('server', 'text', (col) => col.notNull())
.addColumn('name', 'text', (col) => col.notNull())
.addColumn('email', 'text', (col) => col.notNull())
.addColumn('avatar', 'text')
@@ -41,6 +100,8 @@ const createWorkspacesTable: Migration = {
};
export const appDatabaseMigrations: Record<string, Migration> = {
'00001_create_accounts_table': createAccountsTable,
'00002_create_workspaces_table': createWorkspacesTable,
'00001_create_servers_table': createServersTable,
'00002_add_neuron_servers': addNeuronServers,
'00003_create_accounts_table': createAccountsTable,
'00004_create_workspaces_table': createWorkspacesTable,
};

View File

@@ -1,7 +1,22 @@
import { ColumnType, Insertable, Selectable, Updateable } from 'kysely';
interface ServerTable {
domain: ColumnType<string, string, never>;
name: ColumnType<string, string, string>;
avatar: ColumnType<string, string, string>;
attributes: ColumnType<string, string, string>;
version: ColumnType<string, string, string>;
created_at: ColumnType<string, string, string>;
last_synced_at: ColumnType<string | null, string | null, string>;
}
export type SelectServer = Selectable<ServerTable>;
export type CreateServer = Insertable<ServerTable>;
export type UpdateServer = Updateable<ServerTable>;
interface AccountTable {
id: ColumnType<string, string, never>;
server: ColumnType<string, string, never>;
name: ColumnType<string, string, string>;
email: ColumnType<string, string, never>;
avatar: ColumnType<string | null, string | null, string | null>;
@@ -31,6 +46,7 @@ export type CreateWorkspace = Insertable<WorkspaceTable>;
export type UpdateWorkspace = Updateable<WorkspaceTable>;
export interface AppDatabaseSchema {
servers: ServerTable;
accounts: AccountTable;
workspaces: WorkspaceTable;
}

View File

@@ -0,0 +1,9 @@
import { Server } from '@/types/servers';
export class ServerManager {
public readonly server: Server;
constructor(server: Server) {
this.server = server;
}
}

View File

@@ -0,0 +1,41 @@
import { useAppDatabase } from '@/contexts/app-database';
import { SelectServer } from '@/data/schemas/app';
import { Server } from '@/types/servers';
import { useQuery } from '@tanstack/react-query';
import { QueryResult } from 'kysely';
export const useServersQuery = () => {
const appDatabase = useAppDatabase();
return useQuery<QueryResult<SelectServer>, Error, Server[], string[]>({
queryKey: ['servers'],
queryFn: async ({ queryKey }) => {
const query = appDatabase.database
.selectFrom('servers')
.selectAll()
.compile();
const servers = await appDatabase.queryAndSubscribe({
key: ['servers'],
query: query,
});
return servers;
},
select: (data: QueryResult<SelectServer>): Server[] => {
const rows = data?.rows ?? [];
return rows.map(mapServer);
},
});
};
const mapServer = (row: SelectServer): Server => {
return {
domain: row.domain,
name: row.name,
avatar: row.avatar,
attributes: JSON.parse(row.attributes),
version: row.version,
createdAt: new Date(row.created_at),
lastSyncedAt: row.last_synced_at ? new Date(row.last_synced_at) : null,
};
};

View File

@@ -13,4 +13,5 @@ export type Account = {
token: string;
deviceId: string;
status: string;
server: string;
};

View File

@@ -0,0 +1,13 @@
export type Server = {
domain: string;
name: string;
avatar: string;
attributes: ServerAttributes;
version: string;
createdAt: Date;
lastSyncedAt: Date | null;
};
export type ServerAttributes = {
name: string;
};