add group creation

This commit is contained in:
2026-01-07 10:25:33 +01:00
parent 090b3c10d1
commit 624e62822c
12 changed files with 330 additions and 94 deletions

View File

@@ -1,5 +1,6 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "bun-react-template",
@@ -10,7 +11,8 @@
"@mui/icons-material": "7.3.5",
"@mui/material": "7.3.5",
"cookie": "^1.0.2",
"mobx-react-lite": "^4.1.1",
"mobx": "^6.15.0",
"mobx-react": "^9.2.1",
"react": "19",
"react-dom": "19",
"react-router-dom": "^7.9.5",
@@ -538,6 +540,8 @@
"mobx": ["mobx@6.15.0", "", {}, "sha512-UczzB+0nnwGotYSgllfARAqWCJ5e/skuV2K/l+Zyck/H6pJIhLXuBnz+6vn2i211o7DtbE78HQtsYEKICHGI+g=="],
"mobx-react": ["mobx-react@9.2.1", "", { "dependencies": { "mobx-react-lite": "^4.1.1" }, "peerDependencies": { "mobx": "^6.9.0", "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-WJNNm0FB2n0Z0u+jS1QHmmWyV8l2WiAj8V8I/96kbUEN2YbYCoKW+hbbqKKRUBqElu0llxM7nWKehvRIkhBVJw=="],
"mobx-react-lite": ["mobx-react-lite@4.1.1", "", { "dependencies": { "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "mobx": "^6.9.0", "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-iUxiMpsvNraCKXU+yPotsOncNNmyeS2B5DKL+TL6Tar/xm+wwNJAubJmtRSeAoYawdZqwv8Z/+5nPRHeQxTiXg=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],

Binary file not shown.

View File

@@ -16,7 +16,8 @@
"@mui/icons-material": "7.3.5",
"@mui/material": "7.3.5",
"cookie": "^1.0.2",
"mobx-react-lite": "^4.1.1",
"mobx": "^6.15.0",
"mobx-react": "^9.2.1",
"react": "19",
"react-dom": "19",
"react-router-dom": "^7.9.5"

View File

@@ -1,8 +1,16 @@
import SignIn from './components/SignIn';
import { createTheme, ThemeProvider } from '@mui/material';
import { useStore } from './Store';
import SignIn from './components/authentication/SignIn';
import { createTheme, CssBaseline, ThemeProvider } from '@mui/material';
import { useStore, type View } from './Store';
import Group from './components/Group';
import { observer } from 'mobx-react-lite';
import { type ReactElement } from 'react';
import NewGroup from './components/authentication/NewGroup';
export const views: Record<View, ReactElement> = {
signIn: <SignIn />,
group: <Group />,
newGroup: <NewGroup />,
};
const App = observer(() => {
const theme = createTheme({
@@ -12,15 +20,18 @@ const App = observer(() => {
});
const store = useStore();
const { loggedIn } = store;
const { currentView } = store;
return (
<ThemeProvider theme={theme}>
<div className="app">
{loggedIn ?
<Group /> :
<SignIn />
}
<CssBaseline enableColorScheme />
<div className="app"
style={{
backgroundImage: 'radial-gradient(at 50% 50%, hsla(210, 100%, 16%, 0.5), hsl(220, 30%, 5%))',
backgroundRepeat: 'no-repeat',
}}
>
{views[currentView]}
</div>
</ThemeProvider>
);

View File

@@ -1,36 +1,64 @@
import { createContext, useContext } from 'react';
import { makeAutoObservable } from 'mobx';
import cookie from 'cookie';
import type { Group, User } from '@/interfaces';
import { makeAutoObservable } from 'mobx';
export type CookieData = {
user: User;
group: Group;
};
export type View = 'signIn' | 'group' | 'newGroup';
export class Store {
cookieData: CookieData | null = null;
loggedIn = false;
currentView: View = 'signIn';
user: User | null = null;
group: Group | null = null;
processCookie() {
const parsed = cookie.parse(document.cookie);
this.cookieData = {
user: parsed.user ? JSON.parse(parsed.user) : null,
group: parsed.group ? JSON.parse(parsed.group) : null,
} as CookieData;
setCurrentView = (view: View) => {
this.currentView = view;
}
get loggedIn(): boolean {
if (this.cookieData === null) {
return false;
};
return !!this.cookieData.user && !!this.cookieData.group;
setLoggedIn = (value: boolean) => {
this.loggedIn = value;
}
setUser = (user: User | null) => {
this.user = user;
}
setGroup = (group: Group | null) => {
this.group = group;
}
logout = () => {
void cookieStore.delete('user');
void cookieStore.delete('group');
this.user = null;
this.group = null;
this.loggedIn = false;
this.currentView = 'signIn';
}
processCookie = () => {
const parsed = cookie.parse(document.cookie);
if (!parsed.user || !parsed.group) {
this.loggedIn = false;
return;
}
this.user = JSON.parse(parsed.user) as User[][0];
this.group = JSON.parse(parsed.group) as Group[][0];
this.loggedIn = !!(this.user && this.group);
}
constructor() {
console.log('Initializing Store');
this.processCookie();
makeAutoObservable(this);
this.processCookie();
if (this.loggedIn) {
this.setCurrentView('group');
}
}
}

View File

@@ -29,14 +29,12 @@ export default function ForgotPassword({ open, handleClose }: ForgotPasswordProp
}}
>
<DialogTitle>Gruppencode vergessen</DialogTitle>
<DialogContent
sx={{ display: 'flex', flexDirection: 'column', gap: 2, width: '100%' }}
>
<DialogContentText>
Gib deine E-Mail Adresse ein und wir senden dir eine E-Mail mit deinem Gruppencode zu.
</DialogContentText>
<OutlinedInput
autoFocus
required
@@ -49,10 +47,8 @@ export default function ForgotPassword({ open, handleClose }: ForgotPasswordProp
fullWidth
/>
</DialogContent>
<DialogActions sx={{ pb: 3, px: 3 }}>
<Button onClick={handleClose}>Cancel</Button>
<Button variant="contained" type="submit">
Continue
</Button>

View File

@@ -1,30 +1,26 @@
import * as React from 'react';
import Box from '@mui/material/Box';
import CssBaseline from '@mui/material/CssBaseline';
import Typography from '@mui/material/Typography';
import Stack from '@mui/material/Stack';
import { useTheme } from '@mui/material/styles';
import Card from './Card';
import { useStore } from '../Store';
import { CardHeader, IconButton } from '@mui/material';
import LogoutIcon from '@mui/icons-material/Logout';
const Group = () => {
const [loading, setLoading] = React.useState(false);
const theme = useTheme();
const store = useStore();
const { group } = store.cookieData!;
const { group, logout } = store;
console.log('Group component rendered with group:', group.name);
if (!group) {
return (<Typography>Keine Gruppe gefunden.</Typography>);
}
return (
<div style={{
backgroundImage: 'radial-gradient(at 50% 50%, hsla(210, 100%, 16%, 0.5), hsl(220, 30%, 5%))',
backgroundRepeat: 'no-repeat',
}}>
<CssBaseline enableColorScheme />
<div>
<Stack direction="column" justifyContent="space-between"
sx={{
height: 'calc((1 - var(--template-frame-height, 0)) * 100dvh)',
height: '100dvh',
minHeight: '100%',
padding: theme.spacing(2),
[theme.breakpoints.up('sm')]: {
@@ -33,13 +29,14 @@ const Group = () => {
}}
>
<Card variant="outlined">
<Typography
component="h1"
variant="h4"
sx={{ width: '100%', fontSize: 'clamp(2rem, 10vw, 2.15rem)' }}
>
{group.name}
</Typography>
<CardHeader
action={
<IconButton aria-label="logout" onClick={logout}>
<LogoutIcon />
</IconButton>
}
title={group.name}
/>
<Box
sx={{
display: 'flex',

View File

@@ -0,0 +1,196 @@
import * as React from 'react';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Divider from '@mui/material/Divider';
import FormLabel from '@mui/material/FormLabel';
import FormControl from '@mui/material/FormControl';
import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import Stack from '@mui/material/Stack';
import { useTheme } from '@mui/material/styles';
import ForgotPassword from '../ForgotPassword';
import Card from '../Card';
import { createGroup, createUser, fetchUser } from '../../serverApi';
import { observer } from 'mobx-react-lite';
import { useStore } from '../../Store';
import LoginIcon from '@mui/icons-material/Login';
const NewGroup = observer(() => {
const [emailInput, setEmailInput] = React.useState('');
const [emailError, setEmailError] = React.useState('');
const [groupInput, setGroupINput] = React.useState('');
const [groupError, setGroupError] = React.useState('');
const [open, setOpen] = React.useState(false);
const [loading, setLoading] = React.useState(false);
const {setLoggedIn, setCurrentView, setGroup, setUser} = useStore();
const theme = useTheme();
React.useEffect(() => {
const params = new URLSearchParams(window.location.search);
const groupParam = params.get('gruppe');
if (groupParam) {
setGroupINput(groupParam);
}
}, []);
const handleClose = () => {
setOpen(false);
};
const handleCreateGroup = async (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
event.preventDefault();
if (!isMailValid() || !isGroupValid()) return;
setLoading(true);
try {
// handle user
let user = await fetchUser(emailInput);
if (!user) {
user = await createUser(emailInput);
if (!user) throw new Error('Error creating user');
}
// handle group
const groupData = await createGroup({ name: groupInput, mail: emailInput });
if (!groupData) throw new Error('Error creating group');
await cookieStore.set('user', JSON.stringify(user));
await cookieStore.set('group', JSON.stringify(groupData));
setUser(user);
setGroup(groupData);
setLoggedIn(true);
setCurrentView('group');
}
catch (error) {
console.error('Error during group creation:', error);
} finally {
setLoading(false);
}
};
const isMailValid = (): boolean => {
let isValid = true;
if (emailInput === '') { return true; }
if (!/\S+@\S+\.\S+/.test(emailInput)) {
setEmailError('Bitte gib eine gültige E-Mail Adresse ein.');
isValid = false;
} else {
setEmailError('');
}
return isValid;
};
const isGroupValid = (): boolean => {
let isValid = true;
if (groupInput === '') { return true; }
if (!/^[A-Z0-9]{6,}$/.test(groupInput)) {
setGroupError('Bitte gib einen gültigen Gruppenname ein. [A-Z, 0-9, mindestens 6 Zeichen]');
isValid = false;
} else {
setGroupError('');
}
return isValid;
};
const handleClickSignIn = () => {
setCurrentView('signIn');
};
return (
<div>
<Stack direction="column" justifyContent="space-between"
sx={{
height: '100dvh',
minHeight: '100%',
padding: theme.spacing(2),
[theme.breakpoints.up('sm')]: {
padding: theme.spacing(4),
},
}}
>
<Card variant="outlined">
<Typography
component="h1"
variant="h4"
sx={{ width: '100%', fontSize: 'clamp(2rem, 10vw, 2.15rem)' }}
>
{'Neue Gruppe erstellen'}
</Typography>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
width: '100%',
gap: 2,
}}
>
<FormControl>
<FormLabel htmlFor="email">E-Mail des Gruppen-Administrator</FormLabel>
<TextField
error={emailError !== ''}
helperText={emailError}
id="email"
type="email"
name="email"
placeholder="ren@tier.de"
autoComplete="email"
required
fullWidth
variant="outlined"
onBlur={isMailValid}
onChange={event => setEmailInput(event.target.value.trim())}
value={emailInput}
color={emailError ? 'error' : 'primary'} />
</FormControl>
<FormControl>
<FormLabel>Gruppenname</FormLabel>
<TextField
error={groupError !== ''}
helperText={groupError}
name="groupcode"
placeholder="GRUPPE123"
type="text"
id="groupcode"
autoComplete="current-groupcode"
required
fullWidth
variant="outlined"
onBlur={() => void isGroupValid()}
onChange={event => setGroupINput(event.target.value.toUpperCase())}
value={groupInput}
color={groupError ? 'error' : 'primary'}
/>
</FormControl>
<ForgotPassword open={open} handleClose={handleClose} />
<Button
fullWidth
variant="contained"
onClick={event => {void handleCreateGroup(event)}}
loading={loading}
>
{'Gruppe erstellen'}
</Button>
</Box>
<Divider>oder</Divider>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Button
fullWidth
variant="outlined"
onClick={handleClickSignIn}
startIcon={<LoginIcon />}
>
{'Gruppe beitreten'}
</Button>
</Box>
</Card>
</Stack>
</div>
);
});
export default NewGroup;

View File

@@ -1,7 +1,6 @@
import * as React from 'react';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import CssBaseline from '@mui/material/CssBaseline';
import Divider from '@mui/material/Divider';
import FormLabel from '@mui/material/FormLabel';
import FormControl from '@mui/material/FormControl';
@@ -11,26 +10,28 @@ import Typography from '@mui/material/Typography';
import Stack from '@mui/material/Stack';
import { useTheme } from '@mui/material/styles';
import GroupAddIcon from '@mui/icons-material/GroupAdd';
import ForgotPassword from './ForgotPassword';
import Card from './Card';
import { createUser, fetchGroupByCode, fetchUser } from '../serverApi';
import ForgotPassword from '../ForgotPassword';
import Card from '../Card';
import { createUser, fetchGroupByCode, fetchUser } from '../../serverApi';
import { observer } from 'mobx-react-lite';
import { useStore } from '../../Store';
const SignIn = observer(() => {
const [email, setEmail] = React.useState('');
const [emailInput, setEmailInput] = React.useState('');
const [emailError, setEmailError] = React.useState('');
const [group, setGroup] = React.useState('');
const [groupInput, setGroupInput] = React.useState('');
const [groupError, setGroupError] = React.useState('');
const [open, setOpen] = React.useState(false);
const [loading, setLoading] = React.useState(false);
const {setLoggedIn, setCurrentView, setGroup, setUser} = useStore();
const theme = useTheme();
React.useEffect(() => {
const params = new URLSearchParams(window.location.search);
const groupParam = params.get('gruppe');
if (groupParam) {
setGroup(groupParam);
setGroupInput(groupParam);
}
}, []);
@@ -49,23 +50,27 @@ const SignIn = observer(() => {
setLoading(true);
try {
const groupData = await fetchGroupByCode(group);
const groupData = await fetchGroupByCode(groupInput);
if (!groupData) {
setGroupError('Gruppencode existiert nicht.');
return;
}
let user = await fetchUser(email);
let user = await fetchUser(emailInput);
if (!user) {
user = await createUser(email);
user = await createUser(emailInput);
if (!user) throw new Error('Error creating user');
}
await cookieStore.set('user', JSON.stringify(user));
await cookieStore.set('group', JSON.stringify(groupData));
setGroup(groupData);
setUser(user);
setLoggedIn(true);
setCurrentView('group');
}
catch (error) {
console.error('Error during sign-in process:', error);
@@ -75,7 +80,7 @@ const SignIn = observer(() => {
const isMailValid = (): boolean => {
let isValid = true;
if (!/\S+@\S+\.\S+/.test(email)) {
if (!/\S+@\S+\.\S+/.test(emailInput)) {
setEmailError('Bitte gib eine gültige E-Mail Adresse ein.');
isValid = false;
} else {
@@ -86,7 +91,7 @@ const SignIn = observer(() => {
const isGroupValid = (): boolean => {
let isValid = true;
if (!/^[A-Z0-9]{6}$/.test(group)) {
if (!/^[A-Z0-9]{6}$/.test(groupInput)) {
setGroupError('Bitte gib einen gültigen Gruppencode ein.');
isValid = false;
} else {
@@ -95,15 +100,15 @@ const SignIn = observer(() => {
return isValid;
};
const handleClickNewGroup = () => {
setCurrentView('newGroup');
}
return (
<div style={{
backgroundImage: 'radial-gradient(at 50% 50%, hsla(210, 100%, 16%, 0.5), hsl(220, 30%, 5%))',
backgroundRepeat: 'no-repeat',
}}>
<CssBaseline enableColorScheme />
<div>
<Stack direction="column" justifyContent="space-between"
sx={{
height: 'calc((1 - var(--template-frame-height, 0)) * 100dvh)',
height: '100dvh',
minHeight: '100%',
padding: theme.spacing(2),
[theme.breakpoints.up('sm')]: {
@@ -137,13 +142,12 @@ const SignIn = observer(() => {
name="email"
placeholder="ren@tier.de"
autoComplete="email"
autoFocus
required
fullWidth
variant="outlined"
onBlur={isMailValid}
onChange={event => setEmail(event.target.value)}
value={email}
onChange={event => setEmailInput(event.target.value)}
value={emailInput}
color={emailError ? 'error' : 'primary'} />
</FormControl>
<FormControl>
@@ -160,8 +164,8 @@ const SignIn = observer(() => {
fullWidth
variant="outlined"
onBlur={isGroupValid}
onChange={event => setGroup(event.target.value)}
value={group}
onChange={event => setGroupInput(event.target.value.toUpperCase())}
value={groupInput}
color={groupError ? 'error' : 'primary'} />
</FormControl>
<ForgotPassword open={open} handleClose={handleClose} />
@@ -188,7 +192,7 @@ const SignIn = observer(() => {
<Button
fullWidth
variant="outlined"
onClick={() => alert('Sign in with Google')}
onClick={handleClickNewGroup}
startIcon={<GroupAddIcon />}
>
{'Neue Gruppe erstellen'}

View File

@@ -34,7 +34,7 @@ export async function fetchGroupByCode(code: string): Promise<Group | null> {
try {
const res = await fetch(`/api/group/${code}`);
if (!res.ok) return null;
const data: Group = await res.json();
const data: Group | null = await res.json();
return data;
} catch (err) {
console.error('Failed to fetch group:', err);
@@ -42,14 +42,14 @@ export async function fetchGroupByCode(code: string): Promise<Group | null> {
}
}
export async function createGroup(group: Group): Promise<Group | null> {
export async function createGroup(group: Partial<Group>): Promise<Group | null> {
try {
const res = await fetch('/api/group', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ group }),
body: JSON.stringify(group),
});
if (!res.ok) return null;
const data: Group = await res.json();

View File

@@ -14,7 +14,6 @@ const server = serve({
console.log('Received request for user:', mail);
const user = await db.getUserByMail(mail);
console.log('Fetching user with mail:', mail, 'Result:', user);
if (!user) return new Response(JSON.stringify({ error: 'User not found' }), { status: 404 });
return new Response(JSON.stringify(user), { headers: { 'Content-Type': 'application/json' } });
},
@@ -37,7 +36,6 @@ const server = serve({
console.log('Received request for group:', code);
const group = await db.getGroupByCode(code);
console.log('Fetching group with ID:', code, 'Result:', group);
if (!group) return new Response(JSON.stringify({ error: 'Group not found' }), { status: 404 });
return new Response(JSON.stringify(group), { headers: { 'Content-Type': 'application/json' } });
},
@@ -46,7 +44,7 @@ const server = serve({
try {
const { name, mail } = await req.json() as { name: string; mail: string };
console.log('Received request to create group with name:', name, 'and mail:', mail);
const response = db.createGroup({ name, mail });
const response = await db.createGroup({ name, mail });
return new Response(JSON.stringify(response), { headers: { 'Content-Type': 'application/json' } });
} catch (err) {
console.error('Error creating group:', err);

View File

@@ -80,25 +80,25 @@ class DB {
* @param mail: string
* @returns created user
*/
public async createUser(mail: string): Promise<User> {
const user: User = await this.instance`
public async createUser(mail: string): Promise<User | null> {
const user: User[] = await this.instance`
INSERT INTO users (mail) VALUES (${mail})
RETURNING *
`;
return user;
return user[0] ?? null;
}
/**
* Get user by mail
* @param mail: string
* @returns user object or undefined
* @returns user object or null
*/
public async getUserByMail(mail: string): Promise<User | undefined> {
const user: User = await this.instance`
public async getUserByMail(mail: string): Promise<User | null> {
const user: User[] = await this.instance`
SELECT * FROM users WHERE mail = ${mail}
`;
return user;
return user[0] ?? null;
}
/* GROUPS */
@@ -109,25 +109,26 @@ class DB {
* @param mail: string
* @returns object with id of the created group
*/
public async createGroup( {name, mail}: {name: string, mail: string}): Promise<Group> {
public async createGroup( {name, mail}: {name: string, mail: string}): Promise<Group | null> {
const code = Math.random().toString(36).substring(2, 8).toUpperCase();
const group: Group = await this.instance`
const group: Group[] = await this.instance`
INSERT INTO groups (code, name, mail) VALUES (${code}, ${name}, ${mail})
RETURNING *
`;
return group;
console.log('Created group:', group);
return group[0] ?? null;
};
/**
* Get group by ID
* @param id: string
* @returns group object or undefined
* @returns group object or null
*/
public async getGroupByCode(code: string): Promise<Group | undefined> {
const group: Group | undefined = await this.instance`
public async getGroupByCode(code: string): Promise<Group | null> {
const group: Group[] = await this.instance`
SELECT * FROM groups WHERE code = ${code}
`;
return group;
return group[0] ?? null;
}
/* GROUP MEMBER */