diff --git a/.env.template b/.env.template index 11b0b88..5fed5c7 100644 --- a/.env.template +++ b/.env.template @@ -14,3 +14,5 @@ API_KEYS=ADDUIDSYOUWANTTOUSEASAPIKEYS_AS_A_COMMA_SEPERATED_LIST MONGODB_PORT=26182 MONGODB_USER=recapp MONGODB_PW=YOURMONGOPW + +JWT_SECRET=YOURJWTSECRET diff --git a/packages/backend/src/actors/FingerprintStore.ts b/packages/backend/src/actors/FingerprintStore.ts new file mode 100644 index 0000000..7f3c22c --- /dev/null +++ b/packages/backend/src/actors/FingerprintStore.ts @@ -0,0 +1,136 @@ +import { Timestamp, Unit, toTimestamp, unit } from "itu-utils"; +import { ActorRef, ActorSystem } from "ts-actors"; +import { create } from "mutative"; +import { ActorUri, Fingerprint, FingerprintStoreMessage, FingerprintStoreMessages, FingerprintUpdateMessage, Id, User, UserStoreMessage, UserStoreMessages } from "@recapp/models"; +import { identity, pick } from "rambda"; +import { CollecionSubscription, SubscribableActor } from "./SubscribableActor"; + +type FingerprintStoreResult = Unit | Error | Fingerprint | boolean; + +type FingerprintStoreState = { + cache: Map; + lastTouched: Map; + subscribers: Map>; + collectionSubscribers: Map; + lastSeen: Map; +}; + +export class FingerprintStore extends SubscribableActor { + protected override state: FingerprintStoreState = { cache: new Map(), lastTouched: new Map(), subscribers: new Map(), collectionSubscribers: new Map(), lastSeen: new Map() }; + + public constructor(name: string, system: ActorSystem) { + super(name, system, "fingerprints"); + } + + protected override updateIndices(_draft: FingerprintStoreState, _entity: Fingerprint): void { + // No index needed for fingerprints + } + + private updateSubscribers(fingerprint: Partial & { uid : Id }) { + for (const [subscriber, subscription] of this.state.collectionSubscribers) { + this.send( + subscriber, + new FingerprintUpdateMessage( + subscription.properties.length > 0 + ? pick(subscription.properties, fingerprint) + : fingerprint + ) + ); + } + for (const subscriber of this.state.subscribers.get(fingerprint.uid) ?? new Set()) { + this.send(subscriber, new FingerprintUpdateMessage(fingerprint)); + } + } + + public async receive(from: ActorRef, message: FingerprintStoreMessage): Promise { + const [clientUserRole, clientUserId] = await this.determineRole(from); + if (!["ADMIN", "SYSTEM"].includes(clientUserRole)) { + return new Error(`Operation not allowed`); + } + const result = await FingerprintStoreMessages.match>(message, { + StoreFingerprint: async fingerprint => { + this.state = await create(this.state, async draft => { + const currentFingerprint = (await this.getEntity(fingerprint.uid)).orElse({} as Fingerprint); + fingerprint.updated = toTimestamp(); + const newFingerprint = { ...currentFingerprint, ...fingerprint }; + draft.cache.set(fingerprint.uid, newFingerprint); + console.debug("SToring new fingerprint", newFingerprint); + this.storeEntity(newFingerprint); + }); + return unit(); + }, + Get: async id => { + const result = await this.getEntity(id); + const retVal = result.match(identity, () => new Error(`Unknown fingerprint id ${id}`)); + return Promise.resolve(retVal) + }, + Block: async id => { + const mbFingerprint = await this.getEntity(id) + const {uid} = await this.ask("actors://recapp-backend/UserStore", UserStoreMessages.GetByFingerprint(id)); + return mbFingerprint.match( + fp => { + this.storeEntity({...fp, blocked: true}); + this.updateSubscribers({...fp, blocked: true}) + if (uid) + this.send("actors://recapp-backend/UserStore", UserStoreMessages.Update({ uid: fp.uid, active: false })); + return unit(); + }, + () => { + return new Error(`Unknown fingerprint id ${id}`); + } + ) + }, + Unblock: async id => { + const mbFingerprint = await this.getEntity(id) + const {uid} = await this.ask("actors://recapp-backend/UserStore", UserStoreMessages.GetByFingerprint(id)); + return mbFingerprint.match( + fp => { + this.storeEntity({...fp, blocked: false}); + this.updateSubscribers({...fp, blocked: false}) + if (uid) + this.send("actors://recapp-backend/UserStore", UserStoreMessages.Update({ uid: fp.uid, active: true })); + return unit(); + }, + () => { + return new Error(`Unknown fingerprint id ${id}`); + } + ) + }, + IncreaseCount: async ({fingerprint, userUid, initialQuiz}) => { + const mbFingerprint = await this.getEntity(fingerprint) + return mbFingerprint.match( + fp => { + this.storeEntity({...fp, usageCount: fp.usageCount + 1, lastSeen: toTimestamp(), userUid, initialQuiz: initialQuiz ?? fp.initialQuiz}); + this.updateSubscribers({...fp, usageCount: fp.usageCount + 1, lastSeen: toTimestamp(), userUid, initialQuiz: initialQuiz ?? fp.initialQuiz}) + return unit(); + }, + () => { + return new Error(`Unknown fingerprint id ${fingerprint}`); + } + ) + }, + GetMostRecent: async () => { + const db = await this.connector.db(); + const fps = await db.collection(this.collectionName).find({}).sort({lastSeen: 1}).limit(100).toArray(); + fps.forEach(fp => { + const { _id, ...rest } = fp; + this.send(from, new FingerprintUpdateMessage(rest)); + }); + return unit(); + }, + SubscribeToCollection: async () => { + this.state = create(this.state, draft => { + draft.lastSeen.set(from.name as ActorUri, toTimestamp()); + draft.collectionSubscribers.set(from.name as ActorUri, { + properties: [], + userId: clientUserId, + userRole: clientUserRole, + }); + }); + return unit(); + }, + default: async () => new Error(`Unknown message from ${from.name}: ${JSON.stringify(message, undefined, 2)}`), + }); + return result; + } +} diff --git a/packages/backend/src/actors/QuizActor.ts b/packages/backend/src/actors/QuizActor.ts index e2610f1..f7a3636 100644 --- a/packages/backend/src/actors/QuizActor.ts +++ b/packages/backend/src/actors/QuizActor.ts @@ -200,9 +200,12 @@ export class QuizActor extends SubscribableActor { console.log("QUIZACTOR", from.name, message); try { - const [clientUserRole, clientUserId] = await this.determineRole(from); + const [clientUserRole, clientUserId, clientIsTemporary] = await this.determineRole(from); return await QuizActorMessages.match>(message, { Create: async quiz => { + if (clientIsTemporary){ + return serializeError(new Error("Cannot export as a temporary user")); + } return this.create(quiz, clientUserRole, clientUserId); }, GetUserRun: async ({ studentId, quizId }) => { @@ -385,6 +388,9 @@ export class QuizActor extends SubscribableActor { + if (clientIsTemporary){ + return serializeError(new Error("Cannot export as a temporary user")); + } console.log(filename); const jsonBuffer = await readFile(path.join("./downloads", filename)); const importedObject = JSON.parse(jsonBuffer.toString()); @@ -446,6 +452,7 @@ export class QuizActor extends SubscribableActor { + if (clientIsTemporary){ + return serializeError(new Error("Cannot export as a temporary user")); + } const db = await this.connector.db(); const mbQuiz = maybe(await db.collection(this.collectionName).findOne({ uid })); return mbQuiz.match>( @@ -516,6 +526,9 @@ export class QuizActor extends SubscribableActor { + if (clientIsTemporary){ + return serializeError(new Error("Cannot export as a temporary user")); + } try { const filename = (await this.ask(this.ref, QuizActorMessages.Export(uid))) as string | Error; if (typeof filename === "string") { diff --git a/packages/backend/src/actors/StoringActor.ts b/packages/backend/src/actors/StoringActor.ts index 3280da6..296508a 100644 --- a/packages/backend/src/actors/StoringActor.ts +++ b/packages/backend/src/actors/StoringActor.ts @@ -39,13 +39,13 @@ export abstract class StoringActor => { + protected determineRole = async (from: ActorRef): Promise<[UserRole: AccessRole, UserID: Id, IsTemporaryAccount: boolean]> => { if (systemEquals(this.actorRef!, from)) { - return ["SYSTEM", "SYSTEM" as Id]; + return ["SYSTEM", "SYSTEM" as Id, false]; } if (extractSystemName(this.actorRef!.name) === from.name.replace("actors://", "")) { // It's our actor system that's asking directly - return ["SYSTEM", "SYSTEM" as Id]; + return ["SYSTEM", "SYSTEM" as Id, false]; } const session: Session = await this.ask( @@ -55,9 +55,9 @@ export abstract class StoringActor s as Session) .catch((e: Error): Session => { console.error(e); - return { role: "STUDENT", uid: "" } as Session; + return { role: "STUDENT", uid: "", fingerprint: "-"} as Session; }); - return [session.role, session.uid]; + return [session.role, session.uid, !!session.fingerprint]; }; protected async afterEntityRemovedFromCache(_uid: Id) { diff --git a/packages/backend/src/actors/UserStore.ts b/packages/backend/src/actors/UserStore.ts index 749b752..066503f 100644 --- a/packages/backend/src/actors/UserStore.ts +++ b/packages/backend/src/actors/UserStore.ts @@ -19,6 +19,9 @@ import { CollecionSubscription, SubscribableActor } from "./SubscribableActor"; import { AccessRole } from "./StoringActor"; import { maybe } from "tsmonads"; import { createActorUri } from "../utils"; +import { DateTime } from "luxon"; + +const REMOVE_TEMPS_INTERVAL = 1; // 30; // 30 days type ListedUser = Omit; @@ -66,6 +69,25 @@ export class UserStore extends SubscribableActor { + const cleanupTimestamp = toTimestamp(DateTime.utc().minus({ days: REMOVE_TEMPS_INTERVAL })); + const db = await this.connector.db() + const temporaryUsers = await db.collection(this.collectionName).find({ isTemporary: true }).toArray(); + temporaryUsers.forEach(user => { + if (user.lastLogin < cleanupTimestamp) { + this.logger.debug(`Deleting old temporary user ${user.uid} ${user.username}`); + db.collection(this.collectionName).deleteOne({ uid: user.uid }); + this.state.lastTouched.delete(user.uid); + this.state.cache.delete(user.uid); + this.afterEntityRemovedFromCache(user.uid); + } + }) + } + cleanupTemps(); + } + public async receive(from: ActorRef, message: UserStoreMessage): Promise { console.log("USERSTORE", from.name, message); try { @@ -134,6 +156,19 @@ export class UserStore extends SubscribableActor(identity, () => new Error(`User id ${userId} does not exist`)); }, + GetByFingerprint: async fp => { + if (!["ADMIN", "SYSTEM"].includes(clientUserRole)) { + return new Error(`Operation not allowed`); + } + const db = await this.connector.db(); + const maybeUser = maybe( + await db.collection(this.collectionName).findOne({ fingerprint: fp }) + ); + return maybeUser.match( + identity, + () => new Error(`User fingerprint ${fp} does not exist`) + ); + }, GetAll: async () => { if (!["ADMIN", "SYSTEM"].includes(clientUserRole)) { return new Error(`Operation not allowed`); @@ -142,7 +177,9 @@ export class UserStore extends SubscribableActor(this.collectionName).find({}).toArray(); users.forEach(user => { const { _id, quizUsage, ...rest } = user; - this.send(from, new UserUpdateMessage(rest)); + //if (clientUserRole === "SYSTEM" || !rest.isTemporary) { + this.send(from, new UserUpdateMessage(rest)); + //} }); return unit(); }, @@ -232,6 +269,25 @@ export class UserStore extends SubscribableActor { + const db = await this.connector.db(); + const mbUser = maybe(await db.collection(this.collectionName).findOne({uid})); + return mbUser.match( + user => { + if (user.isTemporary) { + this.deleteEntity(user.uid); + this.state.lastTouched.delete(uid); + this.state.cache.delete(uid); + } else { + this.logger.warn(`Trying to remove non-temporary user ${uid}. Operation not allowed and therefore skipped.`); + } + return unit(); + }, + () => { + return new Error(`User not found with id <${uid}>`); + } + ) + }, default: async () => { return new Error(`Unknown message ${JSON.stringify(message)} from ${from.name}`); }, diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 4d13cc0..e3c7d7a 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -16,7 +16,7 @@ import multer from "@koa/multer"; import type { File } from "@koa/multer"; import type { IncomingMessage } from "http"; import koaLogger from "koa-logger-winston"; -import { authLogin, authLogout, authProviderCallback, authRefresh } from "./middlewares/authRoutes"; +import { authLogin, authLogout, authProviderCallback, authRefresh, authTempAccount } from "./middlewares/authRoutes"; import { errorHandler } from "./middlewares/errorHandler"; import { logger } from "./logger"; import { authenticationMiddleware } from "./middlewares/authMiddleware"; @@ -24,6 +24,7 @@ import { QuizActor } from "./actors/QuizActor"; import { ErrorActor } from "./actors/ErrorActor"; import { createReadStream, existsSync } from "fs"; import * as path from "path"; +import { FingerprintStore } from "./actors/FingerprintStore"; const config = { port: parseInt(process.env.SERVER_PORT ?? "3123"), @@ -56,6 +57,7 @@ router .get("/auth/callback", authProviderCallback) .get("/auth/logout", authLogout) .get("/auth/refresh", authRefresh) + .get("/auth/temp", authTempAccount) .get("/ping", ctx => { ctx.status = 200; ctx.body = "PONG"; @@ -105,6 +107,7 @@ const start = async () => { const system = await DistributedActorSystem.create({ distributor, systemName, logger }); Container.set("actor-system", system); await system.createActor(SessionStore, { name: "SessionStore", strategy: "Restart" }); + await system.createActor(FingerprintStore, { name: "FingerprintStore", strategy: "Restart" }); await system.createActor(UserStore, { name: "UserStore", strategy: "Restart" }); await system.createActor(QuizActor, { name: "QuizActor", strategy: "Restart" }); await system.createActor(ErrorActor, { name: "ErrorActor", strategy: "Restart", errorReceiver: true }); diff --git a/packages/backend/src/middlewares/authRoutes.ts b/packages/backend/src/middlewares/authRoutes.ts index 340ccbd..261c0c4 100644 --- a/packages/backend/src/middlewares/authRoutes.ts +++ b/packages/backend/src/middlewares/authRoutes.ts @@ -4,11 +4,12 @@ import { Issuer, Client, ClientMetadata } from "openid-client"; import Container from "typedi"; import koa from "koa"; import { ActorSystem } from "ts-actors"; -import { createActorUri } from "../utils"; -import { Id, Session, SessionStoreMessages, User, UserRole, UserStoreMessages } from "@recapp/models"; +import { calculateFingerprint, createActorUri, createTempJwt } from "../utils"; +import { Fingerprint, FingerprintStoreMessages, Id, Session, SessionStoreMessages, User, UserRole, UserStoreMessages } from "@recapp/models"; import { toTimestamp } from "itu-utils"; import { DateTime } from "luxon"; import { maybe } from "tsmonads"; +import { v4 } from "uuid"; const { BACKEND_URI, OID_CLIENT_ID, OPENID_PROVIDER, ISSUER, OID_CLIENT_SECRET, REDIRECT_URI, REQUIRES_OFFLINE_SCOPE } = process.env; @@ -52,6 +53,111 @@ export const authLogin = async (ctx: koa.Context): Promise => { ctx.redirect(authUrl); }; +export const authTempAccount = async (ctx: koa.Context): Promise => { + // Fingerprint berechnen + const fingerprint = calculateFingerprint(ctx); + const quiz = ctx.query.quiz?.toString(); + const persistentCookie = !!ctx.query.persistent; + // Prüfen ob gesperrt + const system = Container.get("actor-system"); + const userStore = createActorUri("UserStore"); + const fpStore = createActorUri("FingerprintStore"); + const uid = v4() as Id; + + try { + let fpData: Fingerprint | Error = await system.ask(fpStore, FingerprintStoreMessages.Get(fingerprint as Id)); + console.log("New fingerprint", fingerprint, fpData); + if (fpData instanceof Error) { + console.debug("A new fingerprint has been found", fingerprint); + const fp: Fingerprint = { + uid: fingerprint as Id, + created: toTimestamp(), + updated: toTimestamp(), + lastSeen: toTimestamp(), + usageCount: 1, + blocked: false, + userUid: uid, + initialQuiz: quiz && quiz !== "false" ? quiz as Id : undefined, + }; + await system.send(fpStore, FingerprintStoreMessages.StoreFingerprint(fp)); + fpData = fp; + } + await system.send(fpStore, FingerprintStoreMessages.IncreaseCount({fingerprint: fingerprint as Id, userUid: uid as Id, initialQuiz: quiz && quiz !== "false" ? quiz as Id : undefined})); + if (fpData.blocked) { + console.debug("Fingerprint was blocked", fingerprint); + ctx.redirect((process.env.FRONTEND_URI ?? "http://localhost:5173") + "?error=userdeactivated"); + return; + } + } catch (e) { + console.error(e); + console.debug("A new fingerprint has been found", fingerprint); + const fp: Fingerprint = { + uid: fingerprint as Id, + created: toTimestamp(), + updated: toTimestamp(), + lastSeen: toTimestamp(), + usageCount: 1, + blocked: false, + userUid: uid, + initialQuiz: quiz && quiz !== "false" ? quiz as Id : undefined, + }; + await system.send(fpStore, FingerprintStoreMessages.StoreFingerprint(fp)); + await system.send(fpStore, FingerprintStoreMessages.IncreaseCount({fingerprint: fingerprint as Id, userUid: uid as Id, initialQuiz: quiz && quiz !== "false" ? quiz as Id : undefined})); + } + try { + const existingUser: User | Error = await system.ask(userStore, UserStoreMessages.GetByFingerprint(fingerprint)); + if (existingUser instanceof Error) { + console.debug("A new fingerprint has been generated"); + } else if (!existingUser.active) { + ctx.redirect((process.env.FRONTEND_URI ?? "http://localhost:5173") + "?error=userdeactivated"); + return; + } + } catch (e) { + console.error(e); + } + // Wenn nicht, dann Token, temporären User und Session anlegen + const token = createTempJwt(uid, fingerprint); + const decoded = jwt.decode(token) as jwt.JwtPayload; + const sessionStore = createActorUri("SessionStore"); + const expires = DateTime.fromMillis((decoded.exp ?? -1) * 1000).toUTC(); + await system.send( + userStore, + UserStoreMessages.Create({ + uid, + role: "STUDENT", + lastLogin: toTimestamp(), + created: toTimestamp(), + updated: toTimestamp(), + username: "Temporary account", + email: "", + active: true, + quizUsage: new Map(), + isTemporary: true, + fingerprint, + initialQuiz: quiz && quiz !== "false" ? quiz : undefined, + }) + ); + await system.send( + sessionStore, + SessionStoreMessages.StoreSession({ + idToken: token, + accessToken: token, + refreshToken: token, + uid, + idExpires: toTimestamp(expires), + refreshExpires: toTimestamp(expires), + role: "STUDENT", + persistentCookie, + fingerprint, + }) + ); + // Cookie setzen und zurückleiten + const thirtyDaysFromNow = new Date(); + thirtyDaysFromNow.setDate(thirtyDaysFromNow.getDate() + 30); + ctx.set("Set-Cookie", `bearer=${token}; path=/; Expires=${thirtyDaysFromNow.toUTCString()}`); + ctx.redirect(process.env.FRONTEND_URI ?? "http://localhost:5173"); +}; + /** * Callback after login flow is finished * @@ -105,6 +211,7 @@ export const authProviderCallback = async (ctx: koa.Context): Promise => { email: decoded.email ?? "", active: true, quizUsage: new Map(), + isTemporary: false, }) ); } else { @@ -158,6 +265,41 @@ export const authProviderCallback = async (ctx: koa.Context): Promise => { * Also performs a local session logout. */ export const authLogout = async (ctx: koa.Context): Promise => { + const maybeIdToken = maybe(ctx.request.headers.cookie) + .flatMap(cookie => maybe(/bearer=([^;]+)/.exec(cookie))) + .flatMap(match => maybe(match[1])); + + await maybeIdToken.match( + async idToken => { + try { + const { sub } = jwt.decode(idToken) as jwt.JwtPayload; + const system = Container.get("actor-system"); + const sessionStore = createActorUri("SessionStore"); + const session: Session | Error = await system + .ask(sessionStore, SessionStoreMessages.GetSessionForUserId(sub as Id)) + .then(s => s as Session) + .catch((e: Error) => { + return e; + }); + if (session instanceof Error) { + throw session; + } + await system.send(sessionStore, SessionStoreMessages.RemoveSession(session.uid)); + if (session.fingerprint) { + const userStore = createActorUri("UserStore"); + await system.send( + userStore, + UserStoreMessages.Remove(session.uid) + ); + } + } catch (e) { + console.error("authLogout", e); + throw e; + } + }, + () => ctx.throw(401, "Unable to sign out.") + ); + ctx.set("Set-Cookie", `bearer=; path=/; max-age=0`); ctx.redirect(process.env.FRONTEND_URI ?? "http://localhost:5173"); }; @@ -171,14 +313,7 @@ export const authRefresh = async (ctx: koa.Context): Promise => { async idToken => { try { const { sub } = jwt.decode(idToken) as jwt.JwtPayload; - /* if (fromTimestamp(exp! * 1000) < DateTime.local().minus(minutes(45))) { - console.log("Refresh not needed yet"); - console.log( - `Session ${fromTimestamp(exp! * 1000).toISO()} < ${DateTime.local().minus(minutes(45))}` - ); - ctx.body = "O.K."; - return; // We do not need to refresh the token yet - } */ + // Refresh the token const client = await getOidc(); const system = Container.get("actor-system"); @@ -193,6 +328,29 @@ export const authRefresh = async (ctx: koa.Context): Promise => { ctx.set("Set-Cookie", `bearer=; path=/`); ctx.throw(401, "Session unknown"); } + // If this is a temporary account, just return + if (session.fingerprint) { + const fpStore = createActorUri("FingerprintStore"); + try { + let fpData: Fingerprint = await system.ask(fpStore, FingerprintStoreMessages.Get(session.fingerprint as Id)); + await system.ask(fpStore, FingerprintStoreMessages.IncreaseCount({fingerprint: session.fingerprint as Id, userUid: session.uid, initialQuiz: undefined})); + if (fpData.blocked) { + system.send(sessionStore, SessionStoreMessages.RemoveSession(sub as Id)); + ctx.set("Set-Cookie", `bearer=; path=/`); + ctx.throw(401, "Token renewal failure"); + return; + } + } catch (e) { + console.error(e); + } + const token = createTempJwt(session.uid, session.fingerprint); + const thirtyDaysFromNow = new Date(); + thirtyDaysFromNow.setDate(thirtyDaysFromNow.getDate() + 30); + ctx.set("Set-Cookie", `bearer=${token}; path=/; expires=${thirtyDaysFromNow.toUTCString()}`); + ctx.body = "O.K."; + return; + } + try { const newTokenSet = await client.refresh(session.refreshToken); const decoded = jwt.decode(newTokenSet.id_token ?? "") as jwt.JwtPayload; diff --git a/packages/backend/src/utils.ts b/packages/backend/src/utils.ts index 263fb4e..d86de78 100644 --- a/packages/backend/src/utils.ts +++ b/packages/backend/src/utils.ts @@ -6,10 +6,12 @@ import { ActorRef, ActorSystem } from "ts-actors"; import { maybe } from "tsmonads"; import Container from "typedi"; import jwt from "jsonwebtoken"; +import koa from "koa"; +import crypto from "crypto"; export const systemName = "recapp-backend"; -export const createActorUri = (actorName: "SessionStore" | "UserStore" | "QuizActor" | "ErrorActor"): ActorUri => { +export const createActorUri = (actorName: "SessionStore" | "UserStore" | "QuizActor" | "ErrorActor" | "FingerprintStore"): ActorUri => { return `actors://${join(systemName, actorName)}` as ActorUri; }; @@ -37,3 +39,68 @@ export const bearerValid = async (idTokenString: string): Promise => { return Promise.reject(new Error("Unknown user")); } }; + +export const calculateFingerprint = (ctx: koa.Context): string => { + const components = { + userAgent: ctx.get("user-agent") || "", + acceptLanguage: ctx.get("accept-language") || "", + accept: ctx.get("accept") || "", + acceptEncoding: ctx.get("accept-encoding") || "", + + ip: ctx.ip || "", + + secChUa: ctx.get("sec-ch-ua") || "", + secChUaPlatform: ctx.get("sec-ch-ua-platform") || "", + secChUaMobile: ctx.get("sec-ch-ua-mobile") || "", + + doNotTrack: ctx.get("dnt") || "", + + protocol: ctx.protocol || "", + host: ctx.host || "", + origin: ctx.origin || "", + + deviceMemory: ctx.get("device-memory") || "", + hardwareConcurrency: ctx.get("hardware-concurrency") || "", + }; + + const fingerprintString = Object.entries(components) + .sort(([keyA], [keyB]) => keyA.localeCompare(keyB)) + .map(([key, value]) => `${key}:${value}`) + .join("|"); + + const hash = crypto.createHash("sha256").update(fingerprintString).digest("hex"); + + return hash; +}; + +const JWT_SECRET = process.env.JWT_SECRET; + +export const createTempJwt = (userId: Id, fingerprint: string): any => { + if (!JWT_SECRET) { + throw new Error("New secret for JWT generation set in server env"); + } + + const payload: jwt.JwtPayload = { + userId: fingerprint, + sub: userId, + role: "TEMP", + }; + + const options: jwt.SignOptions = { + expiresIn: "30d", + algorithm: "HS256", + audience: "users", + issuer: "recapp", + }; + + // Optionen zusammenführen, übergebene überschreiben defaults + + try { + // Token erstellen + const token = jwt.sign(payload, JWT_SECRET as jwt.Secret, options); + return token; + } catch (error) { + console.error("Error on creating token:", error); + throw error; + } +}; diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 56982e6..b4bb4e8 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -1,7 +1,7 @@ { "name": "@recapp/frontend", "private": true, - "version": "1.5.6", + "version": "1.6.1", "type": "module", "scripts": { "dev": "vite", diff --git a/packages/frontend/src/Activate.tsx b/packages/frontend/src/Activate.tsx index cb31e8b..f89aa9c 100644 --- a/packages/frontend/src/Activate.tsx +++ b/packages/frontend/src/Activate.tsx @@ -1,5 +1,5 @@ -import React, { useEffect } from "react"; -import { Button, Modal } from "react-bootstrap"; +import React, { useEffect, useState } from "react"; +import { Button, Modal, Form } from "react-bootstrap"; import { Trans } from "@lingui/react"; import { useNavigate } from "react-router-dom"; import { cookie } from "./utils"; @@ -8,6 +8,8 @@ export const Activate: React.FC = () => { const error = document.location.search.includes("error=userdeactivated"); const quiz = document.location.search.includes("quiz=") && document.location.search.split("=")[1]; const nav = useNavigate(); + const [showTempReminder, setShowTempReminder] = useState(false); + const [persistentCookie, setPersistentCookie] = useState(true); useEffect(() => { if (cookie("bearer")) { @@ -19,23 +21,79 @@ export const Activate: React.FC = () => { return (
- Login-Fehler - Ihr Account wurde deaktiviert. Bitte wenden Sie sich an Ihren Administrator. + + + + + + + + + + + + + + + setPersistentCookie(event.target.checked)} + /> + +   + + + + + + + +

RECAPP

-
Melde dich an um am Quiz teilzunehmen.
+
+ +
+
oder
+ +
+ +
diff --git a/packages/frontend/src/App.tsx b/packages/frontend/src/App.tsx index ba0393b..a82a47b 100644 --- a/packages/frontend/src/App.tsx +++ b/packages/frontend/src/App.tsx @@ -1,6 +1,6 @@ import { createBrowserRouter, RouterProvider } from "react-router-dom"; import { Login } from "./Login"; -import { Dashboard } from "./Dashboard"; +import { Dashboard } from "./pages/Dashboard"; import { useEffect } from "react"; import { dynamicActivate, defaultLocale } from "./i18n"; import { getStoredSelectedLocal } from "./components/layout/LocaleSelect"; diff --git a/packages/frontend/src/FingerprintPanel.tsx b/packages/frontend/src/FingerprintPanel.tsx new file mode 100644 index 0000000..7d9c599 --- /dev/null +++ b/packages/frontend/src/FingerprintPanel.tsx @@ -0,0 +1,59 @@ +import { useState } from "react"; +import { Fingerprint } from "@recapp/models"; +import { useStatefulActor } from "ts-actors-react"; +import { i18n } from "@lingui/core"; +import { Trans } from "@lingui/react"; +import { maybe } from "tsmonads"; + +import InputGroup from "react-bootstrap/InputGroup"; +import Form from "react-bootstrap/Form"; +import { Funnel } from "react-bootstrap-icons"; +import { FingerprintCard } from "./components/cards/FingerprintCard"; +import { TooltipWrapper } from "./components/TooltipWrapper"; + +export const FingerprintPanel: React.FC = () => { + const [fingerprintList] = useStatefulActor<{ fingerprints: Fingerprint[] }>("UserAdmin"); + const [filter, setFilter] = useState(""); + const fingerprints = fingerprintList + .flatMap(fpl => maybe(fpl.fingerprints)) + .map(fpl => + fpl + .filter( + (fp: Fingerprint) => + fp.uid.toLocaleLowerCase().includes(filter) + ) + .slice(0, 50) + ); + return ( +
+

+ +

+
+ + + + + + + setFilter(event.target.value.toLocaleLowerCase())} + /> + +
+
+ {fingerprints.orElse([]).map((fp: Fingerprint) => ( + + ))} + + {/* to fill the empty space so that when a single card is displayed will not take the full width */} +
+
+
+
+
+ ); +}; diff --git a/packages/frontend/src/actorUris.ts b/packages/frontend/src/actorUris.ts index 30e8c3f..b4397ca 100644 --- a/packages/frontend/src/actorUris.ts +++ b/packages/frontend/src/actorUris.ts @@ -2,6 +2,7 @@ import { ActorUri } from "@recapp/models"; export const actorUris: Record = { UserStore: "actors://recapp-backend/UserStore" as ActorUri, + FingerprintStore: "actors://recapp-backend/FingerprintStore" as ActorUri, SessionStore: "actors://recapp-backend/SessionStore" as ActorUri, QuizActor: "actors://recapp-backend/QuizActor" as ActorUri, CommentActorPrefix: "actors://recapp-backend/QuizActor/Comment_" as ActorUri, diff --git a/packages/frontend/src/actors/CreateQuizActor.ts b/packages/frontend/src/actors/CreateQuizActor.ts index 3466df9..bc21078 100644 --- a/packages/frontend/src/actors/CreateQuizActor.ts +++ b/packages/frontend/src/actors/CreateQuizActor.ts @@ -6,6 +6,8 @@ import { actorUris } from "../actorUris"; import unionize, { UnionOf, ofType } from "unionize"; import { clone, keys } from "rambda"; +import { TITLE_MIN_CHARACTERS } from "../constants/constants"; + export type NewQuiz = Omit; export const CreateQuizMessages = unionize( { @@ -70,7 +72,7 @@ export class CreateQuizActor extends StatefulActor => { const validation = { ...this.state.validation }; - validation.title = q.title.trim().length > 0; + validation.title = q.title.trim().length >= TITLE_MIN_CHARACTERS; // return validation; }; diff --git a/packages/frontend/src/actors/CurrentQuizActor.ts b/packages/frontend/src/actors/CurrentQuizActor.ts index 63acc2e..0f0a4fd 100644 --- a/packages/frontend/src/actors/CurrentQuizActor.ts +++ b/packages/frontend/src/actors/CurrentQuizActor.ts @@ -327,6 +327,7 @@ export class CurrentQuizActor extends StatefulActor { +export class UserAdminActor extends StatefulActor { constructor(name: string, system: ActorSystem) { super(name, system); - this.state = { users: [] }; + this.state = { users: [], fingerprints: [] }; } override send(to: string | ActorRef, message: S): void { @@ -21,17 +21,34 @@ export class UserAdminActor extends StatefulActor { UserStoreMessages.SubscribeToCollection(["uid", "username", "role", "active", "lastlogin", "nickname"]) ); } + const fpResult: User = await this.ask("actors://recapp-backend/FingerprintStore", FingerprintStoreMessages.GetMostRecent()); + if (this.state.fingerprints.length === 0 && fpResult) { + this.send( + "actors://recapp-backend/FingerprintStore", + FingerprintStoreMessages.SubscribeToCollection() + ); + } } - async receive(_from: ActorRef, message: UserUpdateMessage): Promise { + async receive(_from: ActorRef, message: UserUpdateMessage | FingerprintUpdateMessage): Promise { console.log("USERADMIN", _from.name, message); if (message.tag == "UserUpdateMessage") { + if (message.user.isTemporary) { + return true; + } this.updateState(draft => { draft.users = draft.users.filter(u => u.uid != message.user.uid); draft.users.push(message.user as User); draft.users.sort((a, b) => a.uid.localeCompare(b.uid)); }); } + if (message.tag == "FingerprintUpdateMessage") { + this.updateState(draft => { + draft.fingerprints = draft.fingerprints.filter(u => u.uid != message.fp.uid); + draft.fingerprints.push(message.fp as Fingerprint); + draft.fingerprints.sort((a, b) => a.lastSeen.value - b.lastSeen.value); + }); + } return true; } } diff --git a/packages/frontend/src/components/cards/CommentCard.tsx b/packages/frontend/src/components/cards/CommentCard.tsx index 98ba85d..2e3c4f0 100644 --- a/packages/frontend/src/components/cards/CommentCard.tsx +++ b/packages/frontend/src/components/cards/CommentCard.tsx @@ -63,6 +63,7 @@ export const CommentCardContent: React.FC< isDisplayedInModal, isCommentSectionVisible, }) => { + const { rendered } = useRendered({ value: comment.text }); // const isQuizTeacher = teachers.includes(userId); @@ -107,7 +108,7 @@ export const CommentCardContent: React.FC<
diff --git a/packages/frontend/src/components/cards/FingerprintCard.tsx b/packages/frontend/src/components/cards/FingerprintCard.tsx new file mode 100644 index 0000000..f90eaf3 --- /dev/null +++ b/packages/frontend/src/components/cards/FingerprintCard.tsx @@ -0,0 +1,90 @@ +import { Fingerprint, FingerprintStoreMessages, Id, Quiz, User } from "@recapp/models"; +import { useStatefulActor } from "ts-actors-react"; +import { i18n } from "@lingui/core"; +import { fromTimestamp, toTimestamp } from "itu-utils"; + +import Card from "react-bootstrap/Card"; +import { CheckCircleFill, CircleFill } from "react-bootstrap-icons"; +import { actorUris } from "../../actorUris"; +import { maybe } from "tsmonads"; + +interface Props { + fingerprint: Fingerprint; +} + +export const FingerprintCard = ({ fingerprint }: Props) => { + const [, actor] = useStatefulActor<{ users: User[] }>("UserAdmin"); + const [quizData] = useStatefulActor<{ + quizzes: Map>; + }>("LocalUser"); + + const toggleActivate = () => { + actor.forEach(a => + a.send(actorUris.FingerprintStore, fingerprint.blocked ? FingerprintStoreMessages.Unblock(fingerprint.uid) : FingerprintStoreMessages.Block(fingerprint.uid)) + ); + }; + + const userQuizzes: Array<{title: string, uid: Id}> = quizData.flatMap(q => maybe(q.quizzes)).map(ql => Array.from(ql.values()).filter(q => q.students?.includes(fingerprint.userUid)).map(q => ({title: q.title ?? "", uid: q.uid ?? "" as Id}))).orElse([]); + + console.log("Fingerprint:", fingerprint); + + return ( + <> +
+
+ {i18n._({ + id: "fingerprint.card-last-seen: {date}", + values: { + date: fromTimestamp(fingerprint.lastSeen ?? toTimestamp()).toLocaleString({ + dateStyle: "medium", + timeStyle: "medium", + }), + }, + })} +
+ + +

{fingerprint.uid}

+
+ + +
+ + +
+
+ Auth count: {fingerprint.usageCount} +
+
+ {userQuizzes.map(q => { + if (q.uid === fingerprint.initialQuiz) { + return ( + <>{q.title}
+ ); + } + return ( + <>{q.title}
+ ); + })} +
+
+
+
+ + ); +}; + +const BlockedIndicator = (props: { blocked: boolean; onClick: () => void }) => { + return ( +
+ {props.blocked ? ( + + ) : ( + + )} +
+ ); +}; diff --git a/packages/frontend/src/components/cards/QuestionCard.tsx b/packages/frontend/src/components/cards/QuestionCard.tsx index 69e3053..86b2ded 100644 --- a/packages/frontend/src/components/cards/QuestionCard.tsx +++ b/packages/frontend/src/components/cards/QuestionCard.tsx @@ -8,6 +8,7 @@ import { ArrowUp, ArrowDown, Pencil, Trash, Eye, EyeSlash } from "react-bootstra import { ButtonWithTooltip } from "../ButtonWithTooltip"; import { useRendered } from "../../hooks/useRendered"; import { OverviewIcon } from "./OverviewIcon"; +import { TooltipWrapper } from "../TooltipWrapper"; const CONTAINER_MIN_HEIGHT = 160; const ARROW_CONTAINER_MAX_HEIGHT = CONTAINER_MIN_HEIGHT; @@ -65,9 +66,13 @@ export const QuestionCard = (props: Props) => {
- - {i18n._("authored-by", { author: props.question.authorName })} - + { props.question.authorFingerprint ? + + {i18n._("authored-by", { author: props.question.authorName })} + + : + {i18n._("authored-by", { author: props.question.authorName })} + } {props.question.type} diff --git a/packages/frontend/src/components/layout/UserParticipationSelect.tsx b/packages/frontend/src/components/layout/UserParticipationSelect.tsx index 83f0d1d..8541390 100644 --- a/packages/frontend/src/components/layout/UserParticipationSelect.tsx +++ b/packages/frontend/src/components/layout/UserParticipationSelect.tsx @@ -31,6 +31,7 @@ const storeParticipationValue = (value: UserParticipation) => { interface Props { label?: string; + temporary: boolean; } export const UserParticipationSelect = (props: Props) => { @@ -58,6 +59,9 @@ export const UserParticipationSelect = (props: Props) => { label: i18n._("participation-select-option.name") }, }; + if (props.temporary) { + delete (userParticipationOptions as any).NAME; + } useEffect(() => { if (storedValue) { diff --git a/packages/frontend/src/components/modals/ShareQuizModal.tsx b/packages/frontend/src/components/modals/ShareQuizModal.tsx index 210e3d3..3c05301 100644 --- a/packages/frontend/src/components/modals/ShareQuizModal.tsx +++ b/packages/frontend/src/components/modals/ShareQuizModal.tsx @@ -149,20 +149,21 @@ export const ShareQuizModal: React.FC = ({ quiz, show, onClose }) => { value={name} autoFocus // placeholder="ID, Email oder Pseudonym" - placeholder="Email oder Pseudonym" + placeholder={i18n._("email-pseudonym-placeholder")} onChange={event => { const name = event.target.value; setName(name); }} /> - + @@ -172,9 +173,14 @@ export const ShareQuizModal: React.FC = ({ quiz, show, onClose }) => { - + diff --git a/packages/frontend/src/components/quiz-tabs/QuestionsTab.tsx b/packages/frontend/src/components/quiz-tabs/QuestionsTab.tsx index aa318d7..41b0bd1 100644 --- a/packages/frontend/src/components/quiz-tabs/QuestionsTab.tsx +++ b/packages/frontend/src/components/quiz-tabs/QuestionsTab.tsx @@ -133,7 +133,7 @@ export const QuestionsTab: React.FC<{ } else { nav( { pathname: "/Dashboard/Question" }, - { state: { quizId: uid, group, write: writeAccess ? "true" : undefined } } + { state: { questionId: uid, quiz: quizData.quiz.uid, group, write: writeAccess ? "true" : undefined } } ); } }; @@ -282,6 +282,7 @@ export const QuestionsTab: React.FC<{ state: { // group: questionGroup.name, write: writeAccess ? "true" : undefined, + quiz: quizData.quiz.uid }, } ); diff --git a/packages/frontend/src/components/quiz-tabs/QuizButtons.tsx b/packages/frontend/src/components/quiz-tabs/QuizButtons.tsx index 0405c23..36b7bfe 100644 --- a/packages/frontend/src/components/quiz-tabs/QuizButtons.tsx +++ b/packages/frontend/src/components/quiz-tabs/QuizButtons.tsx @@ -313,17 +313,18 @@ export const QuizButtons = (props: {
{props.isQuizTeacher && ( - + )}
diff --git a/packages/frontend/src/components/quiz-tabs/QuizDataTab.tsx b/packages/frontend/src/components/quiz-tabs/QuizDataTab.tsx index 0352e6b..0d57f78 100644 --- a/packages/frontend/src/components/quiz-tabs/QuizDataTab.tsx +++ b/packages/frontend/src/components/quiz-tabs/QuizDataTab.tsx @@ -19,9 +19,9 @@ import axios from "axios"; import { QuizExportModal } from "../modals/QuizExportModal"; import { ButtonWithTooltip } from "../ButtonWithTooltip"; import { ShareQuizModal } from "../modals/ShareQuizModal"; -import { checkIsCreatingQuestionDisabled, debounce } from "../../utils"; +import { checkIsCreatingQuestionDisabled, checkIsParticipationDisabled, debounce } from "../../utils"; import { CharacterTracker } from "../CharacterTracker"; -import { DESCRIPTION_MAX_CHARACTERS, TITLE_MAX_CHARACTERS } from "../../constants/constants"; +import { DESCRIPTION_MAX_CHARACTERS, TITLE_MAX_CHARACTERS, TITLE_MIN_CHARACTERS } from "../../constants/constants"; import { useCurrentQuiz } from "../../hooks/state-actor/useCurrentQuiz"; // import { useCurrentQuiz } from "../../hooks/state-actor/useCurrentQuiz"; // import { keys } from "rambda"; @@ -108,6 +108,7 @@ export const QuizDataTab: React.FC = props => { const isTitleAndDescriptionDisabled = props.disableForStudent || disabledByMode; const isCreatingQuestionDisabled = checkIsCreatingQuestionDisabled(quiz.allowedQuestionTypesSettings); + const isParticipationDisabled = checkIsParticipationDisabled(quiz.studentParticipationSettings); return (
@@ -179,7 +180,7 @@ export const QuizDataTab: React.FC = props => { setTitleAndDescription(prev => ({ ...prev, title: text })); - if (text.length > 3) { + if (text.length >= TITLE_MIN_CHARACTERS) { updateDebounced({ title: text }); setTitleValidationError(""); } else { @@ -275,7 +276,20 @@ export const QuizDataTab: React.FC = props => { /> - + + {/* // remove comment when functionality is implemented + {isParticipationDisabled ? ( + +
+ +
+ + + {i18n._("quiz-data-tab.alert-message.participation-is-disabled")} + +
+ ) : null} */} + {/*

*/} {/* {question.text} */} -

- +

+ {(questionIndex) + 1} /{" "} {questionsList.length}

@@ -149,7 +149,7 @@ export const QuizStatsDetails = ({ {question.type !== "TEXT" && (
-

-

-

= ({ quizDat const run = mbQuiz.flatMap(q => (q?.result && Object.keys(q.result).length > 0 ? maybe(q?.result) : nothing())); const counter = run.map(r => r.counter).orElse(0); - // TODO Eigene Ergebnisse für das Quiz holen + const isTemporaryAccount = mbUser.flatMap(u => maybe(u.user.isTemporary)).orElse(false); + useEffect(() => { if (tryActor.succeeded) { mbUser - .map(u => u.user.uid) + .flatMap(u => maybe(u.user.uid)) .forEach(userId => { // const isStudent = mbQuiz.map(q => q.quiz.students.includes(uid)).orElse(false); - const quizData = mbQuiz - .flatMap(q => (keys(q.quiz).length > 0 ? maybe(q) : nothing())) - .match( - quizData => quizData, - () => null - ); + const quizData = mbQuiz + .flatMap(q => (keys(q.quiz).length > 0 ? maybe(q) : nothing())) + .match( + quizData => quizData, + () => null + ); const isUserInStudentsList = quizData ? isInStudentList(quizData.quiz, userId) : true; console.log("STUD", isUserInStudentsList, run); if (isUserInStudentsList && run.hasValue) { @@ -58,13 +59,11 @@ export const QuizStatsTab: React.FC<{ quizData: CurrentQuizState }> = ({ quizDat }, [tryActor.succeeded, counter]); const exportQuiz = () => { - // TODO: Fragen ob csv oder pdf setShowExportModal(true); tryActor.forEach(actor => actor.send(actor, CurrentQuizMessages.ExportQuizStats())); }; const exportQuestions = () => { - // TODO: Fragen ob csv oder pdf setShowExportModal(true); tryActor.forEach(actor => actor.send(actor, CurrentQuizMessages.ExportQuestionStats())); }; @@ -135,7 +134,7 @@ export const QuizStatsTab: React.FC<{ quizData: CurrentQuizState }> = ({ quizDat

- {i == 0 && !isPresentationModeActive && ( + {i == 0 && !isPresentationModeActive && !isTemporaryAccount && ( <> = ({ quizDat )}
- {group.questions.map((qId, index, arr)=> { + {group.questions.map((qId, index, arr) => { const isLast = arr.length - 1 === index; // This is the overview of all questions const statIndex = quizStats.questionIds.findIndex(f => f === qId)!; @@ -174,7 +173,7 @@ export const QuizStatsTab: React.FC<{ quizData: CurrentQuizState }> = ({ quizDat key={questionId + index + qId} className="m-1xx p-2 pt-3 pb-3" // style={{ backgroundColor: "lightgrey" }} - style={{ borderBottom: !isLast ? "2px solid lightgray" : undefined }} + style={{ borderBottom: !isLast ? "2px solid lightgray" : undefined }} >
@@ -190,9 +189,9 @@ export const QuizStatsTab: React.FC<{ quizData: CurrentQuizState }> = ({ quizDat tryActor.forEach(actor => actor.send( actor, - CurrentQuizMessages.ActivateQuestionStats( - questionId - ) + CurrentQuizMessages.ActivateQuestionStats( + questionId + ) ) ) } @@ -223,28 +222,30 @@ export const QuizStatsTab: React.FC<{ quizData: CurrentQuizState }> = ({ quizDat
- {!isNil(ownCorrectAnswers[qId]) && ( - <> - {/* (:{" "} */} - {/* {ownCorrect ? CHECK_SYMBOL : X_SYMBOL}) */} - - ({ownCorrect ? ( - - ) : ( - - )}) - - {/* ) */} - - )} + {!isNil(ownCorrectAnswers[qId]) && ( + <> + {/* (:{" "} */} + {/* {ownCorrect ? CHECK_SYMBOL : X_SYMBOL}) */} + + ( + {ownCorrect ? ( + + ) : ( + + )} + ) + + {/* ) */} + + )}
{noDetails ? ( diff --git a/packages/frontend/src/components/quizzes-panel/QuizCard.tsx b/packages/frontend/src/components/quizzes-panel/QuizCard.tsx index 9565c82..a78ca92 100644 --- a/packages/frontend/src/components/quizzes-panel/QuizCard.tsx +++ b/packages/frontend/src/components/quizzes-panel/QuizCard.tsx @@ -5,7 +5,7 @@ import { i18n } from "@lingui/core"; import { fromTimestamp } from "itu-utils"; import { Quiz, QuestionGroup, toId } from "@recapp/models"; import Card from "react-bootstrap/Card"; -import { Pencil, Play, Share, Trash } from "react-bootstrap-icons"; +import { Pencil, Play, Share, Trash, StopFill } from "react-bootstrap-icons"; import { ButtonWithTooltip } from "../ButtonWithTooltip"; import { QuizStateBadge } from "../QuizStateBadge"; import { useLocalUser } from "../../hooks/state-actor/useLocalUser"; @@ -17,9 +17,10 @@ export const QuizCard: React.FC<{ quiz: Partial; teachers: string[]; onStart: () => void; + onStop: () => void; onShare: () => void; onDelete?: () => void; -}> = ({ quiz, teachers, onShare, onDelete, onStart }) => { +}> = ({ quiz, teachers, onShare, onDelete, onStart, onStop }) => { const nav = useNavigate(); const { localUser } = useLocalUser(); @@ -114,7 +115,17 @@ export const QuizCard: React.FC<{ ) : null} - {isQuizEditable ? ( + {isQuizStateStarted && isAuthorized ? ( + + + + ) : null} + {/*
{isQuizEditable ? ( - ) : null} + ) : null} */} {isAuthorized ? ( { const nav = useNavigate(); - const [filter, setFilter] = useState(""); + const [filter, setFilter] = useState(""); const [shareModal, setShareModal] = useState(""); const [deleteModal, setDeleteModal] = useState(toId("")); const [showDelete, setShowDelete] = useState(false); @@ -56,6 +56,8 @@ export const QuizzesPanel: React.FC = () => { ); }, [state]); + const isNotTemporaryAccount = state.map(s => !s.user?.isTemporary).orElse(true); + const archiveAllowed = (quiz: Partial): true | undefined => { const isAdmin = state .map(s => s.user) @@ -90,7 +92,7 @@ export const QuizzesPanel: React.FC = () => { setDeleteModal(toId("")); }; - const filteredQuizzes = (quizzes ?? []).filter(u => u.title?.toLocaleLowerCase().includes(filter)); + const filteredQuizzes = (quizzes ?? []).filter(u => u.title?.toLocaleLowerCase().includes(filter)); return ( <> @@ -107,22 +109,24 @@ export const QuizzesPanel: React.FC = () => { setShareModal("")} />
-
- - -
+ {isNotTemporaryAccount && ( +
+ + +
+ )} @@ -139,16 +143,16 @@ export const QuizzesPanel: React.FC = () => {
-
- s.showArchived).orElse(false)} - onChange={event => - tryLocalUserActor.forEach(q => q.send(q, new ToggleShowArchived(event.target.checked))) - } - /> -
+
+ s.showArchived).orElse(false)} + onChange={event => + tryLocalUserActor.forEach(q => q.send(q, new ToggleShowArchived(event.target.checked))) + } + /> +
{filteredQuizzes.map(q => { return ( { onStart={() => { nav({ pathname: "/Dashboard/quiz" }, { state: { quizId: q.uid, start: true } }); }} + onStop={() => { + nav({ pathname: "/Dashboard/quiz" }, { state: { quizId: q.uid, stop: true } }); + }} onShare={() => setShareModal(q.uniqueLink!)} onDelete={() => { if (archiveAllowed(q)) { diff --git a/packages/frontend/src/constants/constants.ts b/packages/frontend/src/constants/constants.ts index c7aee07..bba3ac3 100644 --- a/packages/frontend/src/constants/constants.ts +++ b/packages/frontend/src/constants/constants.ts @@ -1,2 +1,3 @@ export const TITLE_MAX_CHARACTERS = 150; export const DESCRIPTION_MAX_CHARACTERS = 1000; +export const TITLE_MIN_CHARACTERS = 1; diff --git a/packages/frontend/src/layout/HeaderSection.tsx b/packages/frontend/src/layout/HeaderSection.tsx index 29458aa..8884bbe 100644 --- a/packages/frontend/src/layout/HeaderSection.tsx +++ b/packages/frontend/src/layout/HeaderSection.tsx @@ -129,7 +129,7 @@ export const HeaderSection: React.FC = () => { {/* */} - + {/* */} diff --git a/packages/frontend/src/locales/de/messages.js b/packages/frontend/src/locales/de/messages.js index 92ba46a..f51a854 100644 --- a/packages/frontend/src/locales/de/messages.js +++ b/packages/frontend/src/locales/de/messages.js @@ -1,2 +1,2 @@ -/*eslint-disable*/module.exports={messages:JSON.parse("{\"login-page.login\":\"Anmelden\",\"comment-editor-modal.link-to-question.checkbox-label\":\"Mit Frage verlinkt\",\"running-quiz-tab.question-header\":\"Frage\",\"running-quiz-tab.button-label.submit\":\"Einreichen\",\"running-quiz-tab.completed-quiz-card.header\":\"Quiz abgeschlossen\",\"running-quiz-tab.completed-quiz-card.quiz-result\":[[\"questionsCountTotal\"],\" Fragen beantwortet. Davon \",[\"questionsCountCorrect\"],\" richtige Antworten.\"],\"question-edit-page.title.edit-question\":\"Frage bearbeiten\",\"question-edit-page.title.new-question\":\"Neue Frage erstellen\",\"question-edit-page.input-label.group-name\":\"Fragengruppe\",\"question-edit-page.input-label.advisory-text\":\"Hinweistext\",\"question-edit-page.input-label.question\":\"Frage\",\"question-edit-page.button-label.edit\":\"Bearbeiten\",\"question-edit-page.button-label.add\":\"Hinzufügen\",\"question-edit-page.input-label.question-type\":\"Fragetyp\",\"share-quiz-modal.error-alert.already-exists\":\"Benutzer ist bereits ausgewählt\",\"share-quiz-modal.error-alert.do-not-exist\":\"Benutzer existiert nicht\",\"comments-container.toggle-button.label\":\"Kommentare anzeigen/ausblenden\",\"new-quiz-title\":\"Titel\",\"quiz-page.quiz-state.label\":\"Quiz-Status\",\"new-quiz-description\":\"Beschreibung\",\"new-quiz-group\":\"Gruppe\",\"comment-card.button-tooltip.upvote\":\"Upvote\",\"close\":\"Schließen\",\"comment-card.button-tooltip.accept\":\"Annehmen\",\"comment-card.button-tooltip.delete\":\"Löschen\",\"see-more-container.button-label\":\"Mehr anzeigen\",\"authored-by\":[\"von \",[\"author\"]],\"question-card.button-tooltip.edit\":\"Bearbeiten\",\"question-card.button-tooltip.change-group\":\"Gruppe ändern\",\"question-card.button-tooltip.approve\":\"Freigeben\",\"question-card.button-tooltip.delete\":\"Löschen\",\"role-name-admin\":\"Admin\",\"role-name-teacher\":\"Lehrperson\",\"role-name-student\":\"Teilnehmende*r\",\"user-card.button-tooltip.edit\":\"Bearbeiten\",\"user-last-login: {date}\":[\"Letze Anmeldung: \",[\"date\"]],\"user-deactivate-user-button\":\"Benutzer deaktivieren\",\"user-activate-user-button\":\"Benutzer aktivieren\",\"user-change-active-flag-modal-title\":\"Benutzerstatus ändern\",\"yes\":\"Ja\",\"no\":\"Nein\",\"change-group-of-question-title\":\"Fragengruppe ändern\",\"change-group\":\"Gruppe ändern\",\"cancel\":\"Abbruch\",\"user-change-username-modal-title\":\"Benutzernamen ändern\",\"okay\":\"OK\",\"error-nickname-to-short\":\"Pseudonym ist nicht lang genug\",\"error-nickname-already-used\":\"Pseudonym ist bereits vergeben\",\"user-change-nickname-modal-title\":\"Pseudonym ändern\",\"delete-nickname-button\":\"Pseudonym löschen\",\"user-set-user-role-modal-title\":\"Benutzerrolle ändern\",\"user-set-role\":\"Rolle aktualisieren\",\"quiz-create-new-group-modal-title\":\"Name der Fragengruppe\",\"quiz-create-new-group-error-group-name-empty\":\"Gruppenname darf nicht leer sein\",\"quiz-create-new-group-error-group-name-not-unique\":\"Gruppenname muss innerhalb des Quiz eindeutig sein\",\"undefined-title\":\"undefined-title\",\"button-login-again\":\"Erneut anmelden\",\"quiz-export-modal-title\":\"Daten exportieren\",\"quiz-export-modal-text-pending\":\"Export wird vorbereitet.\",\"quiz-export-modal-text-ready\":\"Export abgeschlossen. Sie können die Datei jetzt herunterladen.\",\"download\":\"Herunterladen\",\"share-qr-code-header\":\"Teilnehmende können sich jetzt über Code oder Link in das Quiz einschreiben\",\"share-with-teachers-modal-title\":\"Lehrpersonen zum Teilen auswählen\",\"share-quiz-modal.button-tooltip.clear\":\"Löschen\",\"share-with-teachers-persons-to-add\":\"Hinzuzufügende Personen\",\"share-quiz-modal.button-label.add\":\"Hinzufügen\",\"share-with-confirmed-users\":\"Mit bestätigten Nutzern teilen\",\"quiz-card-number-of-questions\":[[\"count\"],\" Fragen\"],\"quiz-card-number-of-participants\":[[\"count\"],\" Teilnehmende \"],\"quiz-show-qr-code-button\":\"QR-Code anzeigen\",\"quiz-questions-tab-add-group-button\":\"Gruppe hinzufügen\",\"quiz-questions-tab-new-question-button\":\"Neue Frage\",\"button-label-edit\":\"bearbeiten\",\"quiz-questions-tab-empty-group-message\":\"Noch keine Fragen hinzugefügt worden\",\"start-quiz-mode-button\":\"Quizmodus starten\",\"freeze-quiz-button\":\"Quiz stoppen\",\"edit-quiz-button\":\"Editiermodus aktivieren\",\"quiz-description\":\"Beschreibung\",\"number-of-participants\":\"Anzahl Teilnehmende\",\"teachers\":\"Lehrpersonen\",\"quiz-allows-student-comments\":\"Kommentare von Teilnehmenden im Quizmodus erlauben\",\"quiz-allows-student-questions\":\"Fragen durch Teilnehmende erstellen lassen\",\"quiz-student-participation\":\"Teilnahmemöglichkeiten von Teilnehmenden\",\"quiz-allows-anonymous-participation\":\"Anonyme Teilnahme\",\"quiz-allows-participation-via-nickname\":\"Teilnahme mit Pseudonym\",\"quiz-allows-participation-via-realname\":\"Teilnahme mit echtem Namen\",\"quiz-allowed-question-types\":\"Erlaubte Fragentypen\",\"quiz-allows-text-questions\":\"Freitextfragen erlauben\",\"quiz-allows-single-choice-questions\":\"Single-Choice Fragen erlauben\",\"quiz-allows-multiple-choice-realname\":\"Multiple-Choice Fragen erlauben\",\"quiz-enable-question-shufflling\":\"Fragen im Quizmodus mischen\",\"archive-quiz-button\":\"Quiz archivieren\",\"export-quiz-button\":\"Daten exportieren\",\"quiz-data-tab.button-tooltip.edit\":\"Bearbeiten\",\"quiz-data-tab.button-tooltip.share\":\"Teilen\",\"button-label-share\":\"Teilen\",\"export-quiz-statistics-button\":\"Quizstatistik exportieren\",\"export-question-statistics-button\":\"Fragenstatistik exportieren\",\"no-data-yet\":\"Es liegen noch keine auswertbaren Daten vor\",\"details\":\"Details\",\"question-stats-prefix\":\"Frage: \",\"question-stats-info\":[[\"participants\"],\" Teilnehmende von \",[\"maxParticipants\"],\" (\",[\"ratio\"],\" %) haben die Frage bearbeitet\"],\"question-stats-answers-given\":[[\"numberOfAnswers\"],\" Antworten wurden gegeben\"],\"question-stats-given-answers\":\"Es wurden folgenden Antworten gegeben\",\"question-stats-correct-answers\":[[\"passed\"],\" von \",[\"participants\"],\" Antworten waren richtig.\"],\"question-stats-answer-prefix\":\"Antwort: \",\"question-stats-back-to-quiz-button\":\"Zurück zum Quiz\",\"question-stats-previous-question-button\":\"Vorherige Frage\",\"question-stats-next-question-button\":\"Nächste Frage\",\"running-quiz-tab.button-tooltip.comment\":\"Kommentar\",\"quiz-card-last-change\":\"Letzte Änderung\",\"quiz-card.button-tooltip.edit\":\"Bearbeiten\",\"quiz-card.button-tooltip.share\":\"Teilen\",\"quiz-card.button-tooltip.delete\":\"Löschen\",\"button-new-quiz\":\"Neues Quiz\",\"dashboard-tab-label-quizzes\":\"Quizze\",\"dashboard-tab-label-users\":\"Benutzer\",\"header-section.button-tooltip.edit-or-add-pseudonym\":\"Pseudonym bearbeiten oder hinzufügen\",\"header-section.button-tooltip.logout\":\"Abmelden\",\"header.logout\":\"Abmelden\",\"new-quiz\":\"Neues Quiz\",\"quiz-title\":\"Titel\",\"error-quiz-title-too-short\":\"Titel ist zu kurz\",\"create-quiz-button\":\"Quiz anlegen\",\"single-choice-selection\":\"Einzelauswahl\",\"multiple-choice-selection\":\"Mehrfachauswahl\",\"text-type-selection\":\"Freitext\",\"question-edit.button-tooltip.check\":\"Freigeben\",\"question-edit.button-tooltip.edit-title-text\":\"Bearbeiten\",\"question-edit.button-tooltip.edit-comment-text\":\"Kommentar bearbeiten\",\"question-edit.button-tooltip.edit-hint-title\":\"Bearbeiten\",\"activate-all-correct-answers\":\"Aktiviere alle richtigen Antworten\",\"question-edit.button-tooltip.edit-answer\":\"Antwort bearbeiten\",\"question-edit.button-tooltip.delete-answer\":\"Antwort löschen\",\"add-answer-button\":\"Antwort hinzufügen\",\"save-question-button\":\"Frage speichern\",\"quiz-not-found-error-message\":\"Das Quiz konnte nicht geöffnet werden. Das tut uns leid.\",\"back-to-dashboard-link\":\"Zurück zum Dashboard\",\"comment-row-new-comment-button\":\"Neuer Kommentar\",\"user-admin-panel-title\":\"Benutzerverwaltung\",\"user-admin-panel.button-tooltip.filter\":\"Filter\",\"user-admin-panel-search-text\":\"Suchtext\",\"quiz-card-button-tooltip-edit\":\"Bearbeiten\",\"quiz-card-button-tooltip-share\":\"Teilen\",\"quiz-card-button-tooltip-delete\":\"Löschen\",\"quiz-questioins-tab-add-group-button\":\"Gruppe hinzufügen\",\"quiz-tab-label-data\":\"Quizdaten\",\"quiz-tab-label-questions\":\"Quizfragen\",\"quiz-tab-label-statistics\":\"Statistische Auswertung\",\"edit-question-title\":\"Frage bearbeiten\",\"edit-hint-title\":\"Hinweistext bearbeiten\",\"edit-answer-text\":\"Antwort bearbeiten\",\"app.could_not_refresh_token\":\"Anmeldung konnte nicht aktualisiert werden\",\"number-of-quiz-participants\":\"Zahl der Teilnehmenden\",\"teachers-in-quiz\":\"Lehrpersonen mit Zugriff\",\"number-of-quiz-questions\":\"Zahl der Fragen\",\"error-message-no-server-connection\":\"Es konnte keine Verbindung mit dem Server hergestellt werden. Bitte melden Sie sich neu an.\",\"error-message-no-server-connection-title\":\"Keine Serververbindung\",\"error-message-user-deactivated\":\"Ihr Benutzer wurde deaktiviert. Bitte wenden Sie sich an Ihren Administrator.\",\"error-message-user-deactivated-title\":\"Nutzer gesperrt\",\"archive-quiz-title\":\"Quiz archivieren\",\"archive-quiz-text\":\"Möchten Sie dieses Quiz archivieren? Es wird nicht mehr in der Übersicht angezeigt, ist über existierende Links und QR-Codes aber noch erreichbar. Alternativ können Sie das Quiz auch unwiderrufllich löschen. Achtung, es erfolgt keine weitere Rückfrage!\",\"title-set-quiz-mode-edit\":\"Editiermodus starten\",\"info-set-quiz-mode-edit\":\"Mit dem Starten des Editiermodus werden die Statistiken invalidiert. Editieren starten?\",\"title-set-quiz-mode-stopped\":\"Quiz einfrieren\",\"info-set-quiz-mode-stopped\":\"Möchten Sie das Quiz temporär stoppen?\",\"warning-set-quiz-mode-started\":\"Mit dem Start des Quiz werden alle Statistiken gelöscht! Fortfahren?\",\"info-set-quiz-mode-started\":\"Soll der Quizmodus gestartet werden?\",\"title-set-quiz-mode-started\":\"Quizmodus starten\",\"add\":\"Hinzufügen\",\"edit-title-text\":\"Text bearbeiten\",\"remove-edit-mode-of-question-title\":\"Editiermodus wieder aktivieren?\",\"remove-edit-mode-of-question-text\":\"Die Frage befindet ist durch einen anderen Editiervorgang gesperrt worden. Möchten Sie den Editiervorgang wieder aktivieren?\",\"quiz-import-modal-title\":\"Ein Quiz importieren\",\"quiz-import-modal-message\":\"Wählen Sie eine Quiz-Datei aus, um diese zu importieren\",\"import\":\"Importieren\",\"button-import-quiz\":\"Quiz importieren\",\"dashboard-show-archived-quizzes-switch\":\"Archivierte Quizze anzeigen\",\"delete-quiz-button\":\"Quiz unwiderruflich löschen\",\"anonymous\":\"ANONYM\",\"author\":\"Autor\",\"new-comment-title\":\"Neuer Kommentar\",\"back-to-quiz-button\":\"Zurück zum Quiz\",\"question-card.button-tooltip.view\":\"Frage anzeigen\",\"quiz-hide-comments-switch\":\"Kommentarleiste verbergen\",\"leave-quiz-modal-title\":\"Quiz verlassen\",\"leave-quiz-modal-text\":\"Sie sind im Begriff, das Quiz zu verlassen. Es wird Ihnen nicht mehr im Dashboard angezeigt und Sie können nicht länger am Quiz teilnehmen oder es modifizieren. Wirklich verlassen?\",\"quiz-card-teachers-label\":\"Lehrpersonen\",\"answer-correct-title\":\"Antwort richtig\",\"answer-correct\":\"Die Frage wurde richtig beantwortet.\",\"answer-wrong-title\":\"Antwort falsch\",\"answer-wrong\":\"Diese Antwort ist leider falsch\",\"reset-stats-button\":\"Statistik zurücksetzen\",\"title-reset-quiz\":\"Statistik zurücksetzen\",\"info-reset-quiz\":\"Möchten Sie alle erfassten Antworten löschen?\",\"delete-question-title\":\"Frage löschen\",\"delete-question-text\":\"Möchten Sie die Frage unwiderruflich löschen?\",\"question-card.button-tooltip.unapprove\":\"Freigabe zurücknehmen\",\"nickname-not-set\":\"Nicht gesetzt\",\"998VJr\":\"Du\",\"dzcobz\":\"Deine Antwort\"}")}; +/*eslint-disable*/module.exports={messages:JSON.parse("{\"login-page.login\":\"Anmelden\",\"comment-editor-modal.link-to-question.checkbox-label\":\"Mit Frage verlinkt\",\"running-quiz-tab.question-header\":\"Frage\",\"running-quiz-tab.button-label.submit\":\"Einreichen\",\"running-quiz-tab.completed-quiz-card.header\":\"Quiz abgeschlossen\",\"running-quiz-tab.completed-quiz-card.quiz-result\":[[\"questionsCountTotal\"],\" Fragen beantwortet. Davon \",[\"questionsCountCorrect\"],\" richtige Antworten.\"],\"question-edit-page.title.edit-question\":\"Frage bearbeiten\",\"question-edit-page.title.new-question\":\"Neue Frage erstellen\",\"question-edit-page.input-label.group-name\":\"Fragengruppe\",\"question-edit-page.input-label.advisory-text\":\"Hinweistext\",\"question-edit-page.input-label.question\":\"Frage\",\"question-edit-page.button-label.edit\":\"Bearbeiten\",\"question-edit-page.button-label.add\":\"Hinzufügen\",\"question-edit-page.input-label.question-type\":\"Fragetyp\",\"share-quiz-modal.error-alert.already-exists\":\"Benutzer*in ist bereits ausgewählt\",\"share-quiz-modal.error-alert.do-not-exist\":\"Benutzer*in nicht bei RECAPP\",\"comments-container.toggle-button.label\":\"Kommentare anzeigen/ausblenden\",\"new-quiz-title\":\"Titel\",\"quiz-page.quiz-state.label\":\"Quiz-Status\",\"new-quiz-description\":\"Beschreibung\",\"new-quiz-group\":\"Gruppe\",\"comment-card.button-tooltip.upvote\":\"Upvote\",\"close\":\"Schließen\",\"comment-card.button-tooltip.accept\":\"Annehmen\",\"comment-card.button-tooltip.delete\":\"Löschen\",\"see-more-container.button-label\":\"Mehr anzeigen\",\"authored-by\":[\"von \",[\"author\"]],\"question-card.button-tooltip.edit\":\"Bearbeiten\",\"question-card.button-tooltip.change-group\":\"Gruppe ändern\",\"question-card.button-tooltip.approve\":\"Freigeben\",\"question-card.button-tooltip.delete\":\"Löschen\",\"role-name-admin\":\"Admin\",\"role-name-teacher\":\"Lehrperson\",\"role-name-student\":\"Teilnehmende*r\",\"user-card.button-tooltip.edit\":\"Bearbeiten\",\"user-last-login: {date}\":[\"Letze Anmeldung: \",[\"date\"]],\"user-deactivate-user-button\":\"Benutzer*in deaktivieren\",\"user-activate-user-button\":\"Benutzer*in aktivieren\",\"user-change-active-flag-modal-title\":\"Benutzerstatus ändern\",\"yes\":\"Ja\",\"no\":\"Nein\",\"change-group-of-question-title\":\"Fragengruppe ändern\",\"change-group\":\"Gruppe ändern\",\"cancel\":\"Abbruch\",\"user-change-username-modal-title\":\"Benutzernamen ändern\",\"okay\":\"OK\",\"error-nickname-to-short\":\"Pseudonym ist nicht lang genug\",\"error-nickname-already-used\":\"Pseudonym ist bereits vergeben\",\"user-change-nickname-modal-title\":\"Pseudonym ändern\",\"delete-nickname-button\":\"Pseudonym löschen\",\"user-set-user-role-modal-title\":\"Benutzerrolle ändern\",\"user-set-role\":\"Rolle aktualisieren\",\"quiz-create-new-group-modal-title\":\"Name der Fragengruppe\",\"quiz-create-new-group-error-group-name-empty\":\"Gruppenname darf nicht leer sein\",\"quiz-create-new-group-error-group-name-not-unique\":\"Gruppenname muss innerhalb des Quiz eindeutig sein\",\"undefined-title\":\"undefined-title\",\"button-login-again\":\"Erneut anmelden\",\"quiz-export-modal-title\":\"Daten exportieren\",\"quiz-export-modal-text-pending\":\"Export wird vorbereitet.\",\"quiz-export-modal-text-ready\":\"Export abgeschlossen. Sie können die Datei jetzt herunterladen.\",\"download\":\"Herunterladen\",\"share-qr-code-header\":\"Teilnehmende können sich jetzt über Code oder Link in das Quiz einschreiben\",\"share-with-teachers-modal-title\":\"Lehrpersonen zum Teilen auswählen\",\"share-quiz-modal.button-tooltip.clear\":\"Löschen\",\"share-with-teachers-persons-to-add\":\"Hinzuzufügende Personen\",\"share-quiz-modal.button-label.add\":\"Hinzufügen\",\"share-with-confirmed-users\":\"Quiz teilen\",\"quiz-card-number-of-questions\":[[\"count\"],\" Fragen\"],\"quiz-card-number-of-participants\":[[\"count\"],\" Teilnehmende \"],\"quiz-show-qr-code-button\":\"QR-Code anzeigen\",\"quiz-questions-tab-add-group-button\":\"Gruppe hinzufügen\",\"quiz-questions-tab-new-question-button\":\"Neue Frage\",\"button-label-edit\":\"bearbeiten\",\"quiz-questions-tab-empty-group-message\":\"Noch keine Fragen hinzugefügt worden\",\"start-quiz-mode-button\":\"Quizmodus starten\",\"freeze-quiz-button\":\"Quiz stoppen\",\"edit-quiz-button\":\"Editiermodus aktivieren\",\"quiz-description\":\"Beschreibung\",\"number-of-participants\":\"Anzahl Teilnehmende\",\"teachers\":\"Lehrpersonen\",\"quiz-allows-student-comments\":\"Kommentare von Teilnehmenden im Quizmodus erlauben\",\"quiz-allows-student-questions\":\"Fragen durch Teilnehmende erstellen lassen\",\"quiz-student-participation\":\"Teilnahmemöglichkeiten\",\"quiz-allows-anonymous-participation\":\"Anonyme Teilnahme\",\"quiz-allows-participation-via-nickname\":\"Teilnahme mit Pseudonym\",\"quiz-allows-participation-via-realname\":\"Teilnahme mit echtem Namen\",\"quiz-allowed-question-types\":\"Erlaubte Fragentypen\",\"quiz-allows-text-questions\":\"Freitextfragen erlauben\",\"quiz-allows-single-choice-questions\":\"Single-Choice Fragen erlauben\",\"quiz-allows-multiple-choice-realname\":\"Multiple-Choice Fragen erlauben\",\"quiz-enable-question-shufflling\":\"Fragen im Quizmodus mischen\",\"archive-quiz-button\":\"Quiz archivieren\",\"export-quiz-button\":\"Daten exportieren\",\"quiz-data-tab.button-tooltip.edit\":\"Bearbeiten\",\"quiz-data-tab.button-tooltip.share\":\"Teilen\",\"button-label-share\":\"Teilen\",\"export-quiz-statistics-button\":\"Quizstatistik exportieren\",\"export-question-statistics-button\":\"Fragenstatistik exportieren\",\"no-data-yet\":\"Es liegen noch keine auswertbaren Daten vor\",\"details\":\"Details\",\"question-stats-prefix\":\"Frage: \",\"question-stats-info\":[[\"participants\"],\" Teilnehmende von \",[\"maxParticipants\"],\" (\",[\"ratio\"],\" %) haben die Frage bearbeitet\"],\"question-stats-answers-given\":[[\"numberOfAnswers\"],\" Antworten wurden gegeben\"],\"question-stats-given-answers\":\"Es wurden folgenden Antworten gegeben\",\"question-stats-correct-answers\":[[\"passed\"],\" von \",[\"participants\"],\" Antworten waren richtig.\"],\"question-stats-answer-prefix\":\"Antwort: \",\"question-stats-back-to-quiz-button\":\"Zurück zum Quiz\",\"question-stats-previous-question-button\":\"Vorherige Frage\",\"question-stats-next-question-button\":\"Nächste Frage\",\"running-quiz-tab.button-tooltip.comment\":\"Kommentar\",\"quiz-card-last-change\":\"Letzte Änderung\",\"quiz-card.button-tooltip.edit\":\"Bearbeiten\",\"quiz-card.button-tooltip.share\":\"Teilen\",\"quiz-card.button-tooltip.delete\":\"Löschen\",\"button-new-quiz\":\"Neues Quiz\",\"dashboard-tab-label-quizzes\":\"Quizze\",\"dashboard-tab-label-users\":\"Benutzer*in\",\"header-section.button-tooltip.edit-or-add-pseudonym\":\"Pseudonym bearbeiten oder hinzufügen\",\"header-section.button-tooltip.logout\":\"Abmelden\",\"header.logout\":\"Abmelden\",\"new-quiz\":\"Neues Quiz\",\"quiz-title\":\"Titel\",\"error-quiz-title-too-short\":\"Titel ist zu kurz\",\"create-quiz-button\":\"Quiz anlegen\",\"single-choice-selection\":\"Einzelauswahl\",\"multiple-choice-selection\":\"Mehrfachauswahl\",\"text-type-selection\":\"Freitext\",\"question-edit.button-tooltip.check\":\"Freigeben\",\"question-edit.button-tooltip.edit-title-text\":\"Bearbeiten\",\"question-edit.button-tooltip.edit-comment-text\":\"Kommentar bearbeiten\",\"question-edit.button-tooltip.edit-hint-title\":\"Bearbeiten\",\"activate-all-correct-answers\":\"Aktiviere alle richtigen Antworten\",\"activate-correct-answer\":\"Aktiviere die richtige Antwort\",\"question-edit.button-tooltip.edit-answer\":\"Antwort bearbeiten\",\"question-edit.button-tooltip.delete-answer\":\"Antwort löschen\",\"add-answer-button\":\"Antwort hinzufügen\",\"save-question-button\":\"Frage speichern\",\"quiz-not-found-error-message\":\"Das Quiz konnte nicht geöffnet werden. Das tut uns leid.\",\"back-to-dashboard-link\":\"Zurück zum Dashboard\",\"comment-row-new-comment-button\":\"Neuer Kommentar\",\"user-admin-panel-title\":\"Benutzerverwaltung\",\"user-admin-panel.button-tooltip.filter\":\"Filter\",\"user-admin-panel-search-text\":\"Suchtext\",\"quiz-card-button-tooltip-edit\":\"Bearbeiten\",\"quiz-card-button-tooltip-share\":\"Teilen\",\"quiz-card-button-tooltip-delete\":\"Löschen\",\"quiz-questioins-tab-add-group-button\":\"Gruppe hinzufügen\",\"quiz-tab-label-data\":\"Quizdaten\",\"quiz-tab-label-questions\":\"Quizfragen\",\"quiz-tab-label-statistics\":\"Statistische Auswertung\",\"edit-question-title\":\"Frage bearbeiten\",\"edit-hint-title\":\"Hinweistext bearbeiten\",\"edit-answer-text\":\"Antwort bearbeiten\",\"app.could_not_refresh_token\":\"Anmeldung konnte nicht aktualisiert werden\",\"number-of-quiz-participants\":\"Zahl der Teilnehmenden\",\"teachers-in-quiz\":\"Lehrpersonen mit Zugriff\",\"number-of-quiz-questions\":\"Zahl der Fragen\",\"error-message-no-server-connection\":\"Es konnte keine Verbindung mit dem Server hergestellt werden. Bitte melden Sie sich neu an.\",\"error-message-no-server-connection-title\":\"Keine Serververbindung\",\"error-message-user-deactivated\":\"Ihr*e Benutzer*in wurde deaktiviert. Bitte wenden Sie sich an Ihre*n Administrator*in.\",\"error-message-user-deactivated-title\":\"Nutzer*in gesperrt\",\"archive-quiz-title\":\"Quiz archivieren\",\"archive-quiz-text\":\"Möchten Sie dieses Quiz archivieren? Es wird nicht mehr in der Übersicht angezeigt, ist über existierende Links und QR-Codes aber noch erreichbar. Alternativ können Sie das Quiz auch unwiderrufllich löschen. Achtung, es erfolgt keine weitere Rückfrage!\",\"title-set-quiz-mode-edit\":\"Editiermodus starten\",\"info-set-quiz-mode-edit\":\"Mit dem Starten des Editiermodus werden die Statistiken invalidiert. Editieren starten?\",\"title-set-quiz-mode-stopped\":\"Quiz einfrieren\",\"info-set-quiz-mode-stopped\":\"Möchten Sie das Quiz temporär stoppen?\",\"warning-set-quiz-mode-started\":\"Mit dem Start des Quiz werden alle Statistiken gelöscht! Fortfahren?\",\"info-set-quiz-mode-started\":\"Soll der Quizmodus gestartet werden?\",\"title-set-quiz-mode-started\":\"Quizmodus starten\",\"add\":\"Hinzufügen\",\"edit-title-text\":\"Text bearbeiten\",\"remove-edit-mode-of-question-title\":\"Editiermodus wieder aktivieren?\",\"remove-edit-mode-of-question-text\":\"Die Frage befindet ist durch einen anderen Editiervorgang gesperrt worden. Möchten Sie den Editiervorgang wieder aktivieren?\",\"quiz-import-modal-title\":\"Ein Quiz importieren\",\"quiz-import-modal-message\":\"Wählen Sie eine Quiz-Datei aus, um diese zu importieren\",\"import\":\"Importieren\",\"button-import-quiz\":\"Quiz importieren\",\"dashboard-show-archived-quizzes-switch\":\"Archivierte Quizze anzeigen\",\"delete-quiz-button\":\"Quiz unwiderruflich löschen\",\"anonymous\":\"ANONYM\",\"author\":\"Autor\",\"new-comment-title\":\"Neuer Kommentar\",\"back-to-quiz-button\":\"Zurück zum Quiz\",\"question-card.button-tooltip.view\":\"Frage anzeigen\",\"quiz-hide-comments-switch\":\"Kommentarleiste verbergen\",\"leave-quiz-modal-title\":\"Quiz verlassen\",\"leave-quiz-modal-text\":\"Sie sind im Begriff, das Quiz zu verlassen. Es wird Ihnen nicht mehr im Dashboard angezeigt und Sie können nicht länger am Quiz teilnehmen oder es modifizieren. Wirklich verlassen?\",\"quiz-card-teachers-label\":\"Lehrpersonen\",\"answer-correct-title\":\"Antwort richtig\",\"answer-correct\":\"Die Frage wurde richtig beantwortet.\",\"answer-wrong-title\":\"Antwort falsch\",\"answer-wrong\":\"Diese Antwort ist leider falsch\",\"reset-stats-button\":\"Statistik zurücksetzen\",\"title-reset-quiz\":\"Statistik zurücksetzen\",\"info-reset-quiz\":\"Möchten Sie alle erfassten Antworten löschen?\",\"delete-question-title\":\"Frage löschen\",\"delete-question-text\":\"Möchten Sie die Frage unwiderruflich löschen?\",\"question-card.button-tooltip.unapprove\":\"Freigabe zurücknehmen\",\"nickname-not-set\":\"Nicht gesetzt\",\"998VJr\":\"Du\",\"dzcobz\":\"Deine Antwort\"}")}; diff --git a/packages/frontend/src/locales/de/messages.po b/packages/frontend/src/locales/de/messages.po index c86aed5..32d383e 100644 --- a/packages/frontend/src/locales/de/messages.po +++ b/packages/frontend/src/locales/de/messages.po @@ -81,6 +81,10 @@ msgstr "Teilnehmende können Statistiken einsehen" msgid "quiz-data-tab.alert-message.create-new-question-is-disabled" msgstr "Die Schaltfläche 'Neue Frage erstellen' ist deaktiviert." +#. js-lingui-explicit-id +msgid "quiz-data-tab.alert-message.participation-is-disabled" +msgstr "Fragen- und Kommentarerstellung sind für Teilnehmende deaktiviert." + #. js-lingui-explicit-id #: src/components/quiz-tabs/QuestionsTab.tsx:236 msgid "quiz-questions-tab-new-question-button.button-tooltip.create-question-disabled" @@ -219,12 +223,12 @@ msgstr "Fragetyp" #. js-lingui-explicit-id #: src/components/modals/ShareQuizModal.tsx:221 msgid "share-quiz-modal.error-alert.already-exists" -msgstr "Benutzer ist bereits ausgewählt" +msgstr "Benutzer*in ist bereits ausgewählt" #. js-lingui-explicit-id #: src/components/modals/ShareQuizModal.tsx:229 msgid "share-quiz-modal.error-alert.do-not-exist" -msgstr "Benutzer existiert nicht" +msgstr "Benutzer*in nicht bei RECAPP" #. js-lingui-explicit-id #: src/components/cards/CommentsContainer.tsx:44 @@ -329,12 +333,12 @@ msgstr "Letze Anmeldung: {date}" #. js-lingui-explicit-id #: src/components/modals/ChangeActiveModal.tsx:16 msgid "user-deactivate-user-button" -msgstr "Benutzer deaktivieren" +msgstr "Benutzer*in deaktivieren" #. js-lingui-explicit-id #: src/components/modals/ChangeActiveModal.tsx:16 msgid "user-activate-user-button" -msgstr "Benutzer aktivieren" +msgstr "Benutzer*in aktivieren" #. js-lingui-explicit-id #: src/components/modals/ChangeActiveModal.tsx:19 @@ -492,10 +496,18 @@ msgstr "Hinzuzufügende Personen" msgid "share-quiz-modal.button-label.add" msgstr "Hinzufügen" +#. js-lingui-explicit-id +msgid "share-quiz-modal.button-label.add-tooltip" +msgstr "Nur Lehrpersonen, die zuvor auf RecApp zugegriffen haben, können hinzugefügt werden." + #. js-lingui-explicit-id #: src/components/modals/ShareQuizModal.tsx:93 msgid "share-with-confirmed-users" -msgstr "Mit bestätigten Nutzern teilen" +msgstr "Quiz teilen" + +#. js-lingui-explicit-id +msgid "share-with-confirmed-users-tooltip" +msgstr "Quiz erscheint im Dashboard der hinzugefügten Lehrpersonen." #. js-lingui-explicit-id #: src/components/quiz-tabs/QuestionsTab.tsx:191 @@ -589,7 +601,7 @@ msgstr "Fragen durch Teilnehmende erstellen lassen" #: src/components/quiz-tabs/QuizDataTab.tsx:236 #: src/pages/CreateQuiz.tsx:98 msgid "quiz-student-participation" -msgstr "Teilnahmemöglichkeiten von Teilnehmenden" +msgstr "Teilnahmemöglichkeiten" #. js-lingui-explicit-id #: src/components/quiz-tabs/QuizDataTab.tsx:239 @@ -775,6 +787,10 @@ msgstr "Bearbeiten" msgid "quiz-card.button-tooltip.start" msgstr "Starten" +#. js-lingui-explicit-id +msgid "quiz-card.button-tooltip.stop" +msgstr "Stoppen" + #. js-lingui-explicit-id #: src/components/quizzes-panel/QuizCard.tsx:66 msgid "quiz-card.button-tooltip.share" @@ -798,7 +814,7 @@ msgstr "Quizze" #. js-lingui-explicit-id #: src/Dashboard.tsx:44 msgid "dashboard-tab-label-users" -msgstr "Benutzer" +msgstr "Benutzer*in" #. js-lingui-explicit-id #: src/layout/HeaderSection.tsx:88 @@ -875,6 +891,10 @@ msgstr "Bearbeiten" msgid "activate-all-correct-answers" msgstr "Aktiviere alle richtigen Antworten" +#. js-lingui-explicit-id +msgid "activate-correct-answer" +msgstr "Aktiviere die richtige Antwort" + #. js-lingui-explicit-id #: src/pages/QuestionEdit.tsx:460 msgid "question-edit.button-tooltip.edit-answer" @@ -1007,11 +1027,11 @@ msgstr "Suchtext" #. js-lingui-explicit-id #~ msgid "error-message-user-deactivated" -#~ msgstr "Ihr Benutzer wurde deaktiviert. Bitte wenden Sie sich an Ihren Administrator." +#~ msgstr "Ihr*e Benutzer*in wurde deaktiviert. Bitte wenden Sie sich an Ihre*n Administrator*in." #. js-lingui-explicit-id #~ msgid "error-message-user-deactivated-title" -#~ msgstr "Nutzer gesperrt" +#~ msgstr "Nutzer*in gesperrt" #. js-lingui-explicit-id #~ msgid "archive-quiz-title" @@ -1303,9 +1323,72 @@ msgstr "Teilnehmende können Fragen stellen" #. js-lingui-explicit-id msgid "quiz-button-label-end-preview" -msgstr "Vorschau beenden" +msgstr "Lehrpersonenansicht" #. js-lingui-explicit-id msgid "quiz-button-label-start-preview" -msgstr "Vorschau starten" +msgstr "Teilnehmendenansicht" + +#. js-lingui-explicit-id +msgid "quiz-button-tooltip-preview" +msgstr "Startet das Quiz in der Teilnehmeransicht. Das Quiz stoppt, wenn alle beigetretenen Lehrer diesen Modus verlassen." + +#. js-lingui-explicit-id +msgid "login-page.temporary-account-button" +msgstr "Ohne Anmeldung fortfahren" + +#. js-lingui-explicit-id +msgid "login-page.temp-login-header" +msgstr "Quizteilnahme ohne Anmeldung" + +#. js-lingui-explicit-id +msgid "login-page.temp-login-reminder" +msgstr "Du kannst ohne Anmeldung an dem Quiz teilnehmen. Um Missbrauch vorzubeugen, wird für deinen Browser und dein Gerät ein sogenannter Fingerprint erzeugt, mit dem wir dich während deiner Teilnahme identifzieren können. Dem stimmst du durch deine Teilnahme zu." + +#. js-lingui-explicit-id +msgid "login-page.store-cookie-checkbox" +msgstr "Ich möchte dass meine Anmeldung für 30 Tage erhalten bleibt, auch wenn ich das Browserfenster schließe." + +#. js-lingui-explicit-id +msgid "login-page.info-text" +msgstr "Du kannst dich anmelden oder ohne Anmeldung am Quiz teilnehmen." +#. js-lingui-explicit-id +msgid "login-page.account-deactivated-title" +msgstr "Login-Fehler. Account deaktiviert." + +#. js-lingui-explicit-id +msgid "login-page.account-deactivated-message" +msgstr "Ihr Account wurde deaktiviert. Bitte wenden Sie sich an Ihren Administrator." + +#. js-lingui-explicit-id +msgid "fingerprint.card-last-seen: {date}" +msgstr "Letzte Tokenaktualisierung: {date}" + +#. js-lingui-explicit-id +msgid "fingerprint-panel.search-text" +msgstr "Fingerprint suchen" + +#. js-lingui-explicit-id +msgid "fingerprint-panel.button-tooltip.filter" +msgstr "Teile des Fingerprints eingeben um zu filtern" + +#. js-lingui-explicit-id +msgid "fingerprint-panel.title" +msgstr "Fingerprint Verwaltung (Temporäre Accounts)" + +#. js-lingui-explicit-id +msgid "dashboard-tab-label-fingerprints" +msgstr "Temporäre Accounts" + +#. js-lingui-explicit-id +msgid "new-question" +msgstr "Neue Frage" + +#. js-lingui-explicit-id +msgid "question" +msgstr "Frage" + +#. js-lingui-explicit-id +msgid "email-pseudonym-placeholder" +msgstr "Email oder Pseudonym" diff --git a/packages/frontend/src/locales/en/messages.js b/packages/frontend/src/locales/en/messages.js index 9bf5dc8..2993ca5 100644 --- a/packages/frontend/src/locales/en/messages.js +++ b/packages/frontend/src/locales/en/messages.js @@ -1,2 +1,2 @@ -/*eslint-disable*/module.exports={messages:JSON.parse("{\"login-page.login\":\"Login\",\"comment-editor-modal.link-to-question.checkbox-label\":\"Link to question\",\"running-quiz-tab.question-header\":\"Question\",\"running-quiz-tab.button-label.submit\":\"Submit\",\"running-quiz-tab.completed-quiz-card.header\":\"Quiz completed\",\"running-quiz-tab.completed-quiz-card.quiz-result\":[[\"questionsCountTotal\"],\" questions answered. Of which, \",[\"questionsCountCorrect\"],\" correct answers.\"],\"question-edit-page.title.edit-question\":\"Edit question\",\"question-edit-page.title.new-question\":\"New question\",\"question-edit-page.input-label.group-name\":\"Question group\",\"question-edit-page.input-label.advisory-text\":\"Hint text\",\"question-edit-page.input-label.question\":\"Question\",\"question-edit-page.button-label.edit\":\"Edit\",\"question-edit-page.button-label.add\":\"Add\",\"question-edit-page.input-label.question-type\":\"Question type\",\"share-quiz-modal.error-alert.already-exists\":\"User already selected\",\"share-quiz-modal.error-alert.do-not-exist\":\"User doesn't exist\",\"comments-container.toggle-button.label\":\"Show/hide comments\",\"new-quiz-title\":\"Title\",\"quiz-page.quiz-state.label\":\"Quiz state\",\"new-quiz-description\":\"Description\",\"new-quiz-group\":\"Group\",\"comment-card.button-tooltip.upvote\":\"Upvote\",\"close\":\"Close\",\"comment-card.button-tooltip.accept\":\"Accept\",\"comment-card.button-tooltip.delete\":\"Delete\",\"see-more-container.button-label\":\"See more\",\"authored-by\":[\"by \",[\"author\"]],\"question-card.button-tooltip.edit\":\"Edit\",\"question-card.button-tooltip.change-group\":\"Change group\",\"question-card.button-tooltip.approve\":\"Approve\",\"question-card.button-tooltip.delete\":\"Delete\",\"role-name-admin\":\"Admin\",\"role-name-teacher\":\"Teacher\",\"role-name-student\":\"Participant\",\"user-card.button-tooltip.edit\":\"Edit\",\"user-last-login: {date}\":[\"Last login: \",[\"date\"]],\"user-deactivate-user-button\":\"Deactivate user\",\"user-activate-user-button\":\"Activate user\",\"user-change-active-flag-modal-title\":\"Change user status\",\"yes\":\"Yes\",\"no\":\"No\",\"change-group-of-question-title\":\"Change question group\",\"change-group\":\"Change group\",\"cancel\":\"Cancel\",\"user-change-username-modal-title\":\"Change username\",\"okay\":\"OK\",\"error-nickname-to-short\":\"Pseudonym is not long enough\",\"error-nickname-already-used\":\"Pseudonym is already taken\",\"user-change-nickname-modal-title\":\"Change pseudonym\",\"delete-nickname-button\":\"Delete pseudonym\",\"user-set-user-role-modal-title\":\"Change user role\",\"user-set-role\":\"Update role\",\"quiz-create-new-group-modal-title\":\"Name of question group\",\"quiz-create-new-group-error-group-name-empty\":\"Group name must not be empty\",\"quiz-create-new-group-error-group-name-not-unique\":\"Group name must not be empty\",\"undefined-title\":\"undefined title\",\"button-login-again\":\"Log in again\",\"quiz-export-modal-title\":\"Export data\",\"quiz-export-modal-text-pending\":\"Export is being prepared.\",\"quiz-export-modal-text-ready\":\"Export completed. You can now download the file.\",\"download\":\"Download\",\"share-qr-code-header\":\"Participants can now register for the quiz via code or link\",\"share-with-teachers-modal-title\":\"Select teachers for sharing\",\"share-quiz-modal.button-tooltip.clear\":\"Clear\",\"share-with-teachers-persons-to-add\":\"People to be added\",\"share-quiz-modal.button-label.add\":\"Add\",\"share-with-confirmed-users\":\"Share with confirmed users\",\"quiz-card-number-of-questions\":[[\"count\"],\" questions\"],\"quiz-card-number-of-participants\":[[\"count\"],\" participants \"],\"quiz-show-qr-code-button\":\"Show QR code\",\"quiz-questions-tab-add-group-button\":\"Add group\",\"quiz-questions-tab-new-question-button\":\"New question\",\"button-label-edit\":\"edit\",\"quiz-questions-tab-empty-group-message\":\"No questions have been added to the group\",\"start-quiz-mode-button\":\"Start quiz\",\"freeze-quiz-button\":\"Stop quiz\",\"edit-quiz-button\":\"Edit quiz\",\"quiz-description\":\"Description\",\"number-of-participants\":\"Number of participants\",\"teachers\":\"Teachers\",\"quiz-allows-student-comments\":\"Allow participant comments in quiz mode\",\"quiz-allows-student-questions\":\"Allow participants to set questions\",\"quiz-student-participation\":\"Participant participation options\",\"quiz-allows-anonymous-participation\":\"Anonymous participation\",\"quiz-allows-participation-via-nickname\":\"Participation with pseudonym\",\"quiz-allows-participation-via-realname\":\"Participation with real name\",\"quiz-allowed-question-types\":\"Allowed question types\",\"quiz-allows-text-questions\":\"Allow free-text questions\",\"quiz-allows-single-choice-questions\":\"Allow single-choice questions\",\"quiz-allows-multiple-choice-realname\":\"Allow multiple-choice questions\",\"quiz-enable-question-shufflling\":\"Shuffle questions in quiz mode\",\"archive-quiz-button\":\"Archive quiz\",\"export-quiz-button\":\"Export data\",\"quiz-data-tab.button-tooltip.edit\":\"Edit\",\"quiz-data-tab.button-tooltip.share\":\"Share\",\"button-label-share\":\"Share\",\"export-quiz-statistics-button\":\"Export quiz statistics\",\"export-question-statistics-button\":\"Export question statistics\",\"no-data-yet\":\"No analyzable data is available yet\",\"details\":\"Details\",\"question-stats-prefix\":\"Question: \",\"question-stats-info\":[[\"participants\"],\" participants from \",[\"maxParticipants\"],\" (\",[\"ratio\"],\" %) have answered the question\"],\"question-stats-answers-given\":[[\"numberOfAnswers\"],\" answers were given\"],\"question-stats-given-answers\":\"The following answers were given\",\"question-stats-correct-answers\":[[\"passed\"],\" of \",[\"participants\"],\" answers were correct.\"],\"question-stats-answer-prefix\":\"Answer: \",\"question-stats-back-to-quiz-button\":\"Back to Quiz\",\"question-stats-previous-question-button\":\"Previous question\",\"question-stats-next-question-button\":\"Next question\",\"running-quiz-tab.button-tooltip.comment\":\"Comment\",\"quiz-card-last-change\":\"Last change\",\"quiz-card.button-tooltip.edit\":\"Edit\",\"quiz-card.button-tooltip.share\":\"Share\",\"quiz-card.button-tooltip.delete\":\"Delete\",\"button-new-quiz\":\"New quiz\",\"dashboard-tab-label-quizzes\":\"Quizzes\",\"dashboard-tab-label-users\":\"Users\",\"header-section.button-tooltip.edit-or-add-pseudonym\":\"Edit or add pseudonym\",\"header-section.button-tooltip.logout\":\"Logout\",\"header.logout\":\"Logout\",\"new-quiz\":\"New quiz\",\"quiz-title\":\"Title\",\"error-quiz-title-too-short\":\"Title is too short\",\"create-quiz-button\":\"Create quiz\",\"single-choice-selection\":\"Single choice\",\"multiple-choice-selection\":\"Multiple choice\",\"text-type-selection\":\"Free text\",\"question-edit.button-tooltip.check\":\"Select\",\"question-edit.button-tooltip.edit-title-text\":\"Edit\",\"question-edit.button-tooltip.edit-comment-text\":\"Edit comment\",\"question-edit.button-tooltip.edit-hint-title\":\"Edit title\",\"activate-all-correct-answers\":\"Activate all correct answers\",\"question-edit.button-tooltip.edit-answer\":\"Edit answer\",\"question-edit.button-tooltip.delete-answer\":\"Delete answer\",\"add-answer-button\":\"Add answer\",\"save-question-button\":\"Save question\",\"quiz-not-found-error-message\":\"Sorry, the quiz could not be opened\",\"back-to-dashboard-link\":\"Back to dashboard\",\"comment-row-new-comment-button\":\"New comment\",\"user-admin-panel-title\":\"User administration\",\"user-admin-panel.button-tooltip.filter\":\"Filter\",\"user-admin-panel-search-text\":\"Search text\",\"quiz-card-button-tooltip-edit\":\"Edit\",\"quiz-card-button-tooltip-share\":\"Share\",\"quiz-card-button-tooltip-delete\":\"Delete\",\"quiz-questioins-tab-add-group-button\":\"Add group\",\"quiz-tab-label-data\":\"Quiz data\",\"quiz-tab-label-questions\":\"Quiz questions\",\"quiz-tab-label-statistics\":\"Statistical evaluation\",\"edit-question-title\":\"Edit question\",\"edit-hint-title\":\"Edit hint text\",\"edit-answer-text\":\"Edit answer\",\"app.could_not_refresh_token\":\"Login could not be updated\",\"number-of-quiz-participants\":\"Number of participants\",\"teachers-in-quiz\":\"Teaching staff with access\",\"number-of-quiz-questions\":\"Number of questions\",\"error-message-no-server-connection\":\"No connection to the server could be established. Please log in again.\",\"error-message-no-server-connection-title\":\"No server connection\",\"error-message-user-deactivated\":\"Your user has been deactivated. Please contact your administrator.\",\"error-message-user-deactivated-title\":\"User deactivated\",\"archive-quiz-title\":\"Archive quiz\",\"archive-quiz-text\":\"Would you like to archive this quiz? It will no longer be displayed in the overview but can still be accessed via existing links and QR codes. Alternatively, you can delete the quiz permanently. Caution, there will be no further confirmation!\",\"title-set-quiz-mode-edit\":\"Start edit mode\",\"info-set-quiz-mode-edit\":\"Statistics are invalidated when editing mode is started. Start editing?\",\"title-set-quiz-mode-stopped\":\"Freeze quiz\",\"info-set-quiz-mode-stopped\":\"Do you wish to temporarily stop the quiz?\",\"warning-set-quiz-mode-started\":\"All statistics will be deleted when you start the quiz! Continue?\",\"info-set-quiz-mode-started\":\"Should the quiz mode be started?\",\"title-set-quiz-mode-started\":\"Start quiz mode\",\"add\":\"Add\",\"edit-title-text\":\"Edit text\",\"remove-edit-mode-of-question-title\":\"Reactivate edit mode?\",\"remove-edit-mode-of-question-text\":\"The question has been locked by another editing process. Would you like to reactivate the editing process?\",\"quiz-import-modal-title\":\"Import a quiz\",\"quiz-import-modal-message\":\"Select a quiz file to import\",\"import\":\"Import\",\"button-import-quiz\":\"Import quiz\",\"dashboard-show-archived-quizzes-switch\":\"Show archived quizzes\",\"delete-quiz-button\":\"Permanently delete quiz\",\"anonymous\":\"ANONYMOUS\",\"author\":\"Author\",\"new-comment-title\":\"New comment\",\"back-to-quiz-button\":\"Back to quiz\",\"question-card.button-tooltip.view\":\"Show question\",\"quiz-hide-comments-switch\":\"Hide comment list\",\"leave-quiz-modal-title\":\"Leave quiz\",\"leave-quiz-modal-text\":\"You are about to leave the quiz. It will no longer be displayed in your dashboard and you won't be able to modify it or participate. Really leave?\",\"quiz-card-teachers-label\":\"Teaching staff\",\"answer-correct-title\":\"Correct\",\"answer-correct\":\"Your answer is correct.\",\"answer-wrong-title\":\"Incorrect\",\"answer-wrong\":\"Sorry, your answer is incorrect.\",\"reset-stats-button\":\"Reset statistics\",\"title-reset-quiz\":\"Reset statistics\",\"info-reset-quiz\":\"Reset the statistics of all given answers?\",\"delete-question-title\":\"Delete question\",\"delete-question-text\":\"Delete question from quiz? This operation is irrevocable.\",\"question-card.button-tooltip.unapprove\":\"Revoke approval\",\"nickname-not-set\":\"Not set\",\"998VJr\":\"You\",\"dzcobz\":\"Your answer\"}")}; +/*eslint-disable*/module.exports={messages:JSON.parse("{\"login-page.login\":\"Login\",\"comment-editor-modal.link-to-question.checkbox-label\":\"Link to question\",\"running-quiz-tab.question-header\":\"Question\",\"running-quiz-tab.button-label.submit\":\"Submit\",\"running-quiz-tab.completed-quiz-card.header\":\"Quiz completed\",\"running-quiz-tab.completed-quiz-card.quiz-result\":[[\"questionsCountTotal\"],\" questions answered. Of which, \",[\"questionsCountCorrect\"],\" correct answers.\"],\"question-edit-page.title.edit-question\":\"Edit question\",\"question-edit-page.title.new-question\":\"New question\",\"question-edit-page.input-label.group-name\":\"Question group\",\"question-edit-page.input-label.advisory-text\":\"Hint text\",\"question-edit-page.input-label.question\":\"Question\",\"question-edit-page.button-label.edit\":\"Edit\",\"question-edit-page.button-label.add\":\"Add\",\"question-edit-page.input-label.question-type\":\"Question type\",\"share-quiz-modal.error-alert.already-exists\":\"User already selected\",\"share-quiz-modal.error-alert.do-not-exist\":\"User not on RECAPP\",\"comments-container.toggle-button.label\":\"Show/hide comments\",\"new-quiz-title\":\"Title\",\"quiz-page.quiz-state.label\":\"Quiz state\",\"new-quiz-description\":\"Description\",\"new-quiz-group\":\"Group\",\"comment-card.button-tooltip.upvote\":\"Upvote\",\"close\":\"Close\",\"comment-card.button-tooltip.accept\":\"Accept\",\"comment-card.button-tooltip.delete\":\"Delete\",\"see-more-container.button-label\":\"See more\",\"authored-by\":[\"by \",[\"author\"]],\"question-card.button-tooltip.edit\":\"Edit\",\"question-card.button-tooltip.change-group\":\"Change group\",\"question-card.button-tooltip.approve\":\"Approve\",\"question-card.button-tooltip.delete\":\"Delete\",\"role-name-admin\":\"Admin\",\"role-name-teacher\":\"Teacher\",\"role-name-student\":\"Participant\",\"user-card.button-tooltip.edit\":\"Edit\",\"user-last-login: {date}\":[\"Last login: \",[\"date\"]],\"user-deactivate-user-button\":\"Deactivate user\",\"user-activate-user-button\":\"Activate user\",\"user-change-active-flag-modal-title\":\"Change user status\",\"yes\":\"Yes\",\"no\":\"No\",\"change-group-of-question-title\":\"Change question group\",\"change-group\":\"Change group\",\"cancel\":\"Cancel\",\"user-change-username-modal-title\":\"Change username\",\"okay\":\"OK\",\"error-nickname-to-short\":\"Pseudonym is not long enough\",\"error-nickname-already-used\":\"Pseudonym is already taken\",\"user-change-nickname-modal-title\":\"Change pseudonym\",\"delete-nickname-button\":\"Delete pseudonym\",\"user-set-user-role-modal-title\":\"Change user role\",\"user-set-role\":\"Update role\",\"quiz-create-new-group-modal-title\":\"Name of question group\",\"quiz-create-new-group-error-group-name-empty\":\"Group name must not be empty\",\"quiz-create-new-group-error-group-name-not-unique\":\"Group name must not be empty\",\"undefined-title\":\"undefined title\",\"button-login-again\":\"Log in again\",\"quiz-export-modal-title\":\"Export data\",\"quiz-export-modal-text-pending\":\"Export is being prepared.\",\"quiz-export-modal-text-ready\":\"Export completed. You can now download the file.\",\"download\":\"Download\",\"share-qr-code-header\":\"Participants can now register for the quiz via code or link\",\"share-with-teachers-modal-title\":\"Select teachers for sharing\",\"share-quiz-modal.button-tooltip.clear\":\"Clear\",\"share-with-teachers-persons-to-add\":\"People to be added\",\"share-quiz-modal.button-label.add\":\"Add\",\"share-with-confirmed-users\":\"Share quiz\",\"quiz-card-number-of-questions\":[[\"count\"],\" questions\"],\"quiz-card-number-of-participants\":[[\"count\"],\" participants \"],\"quiz-show-qr-code-button\":\"Show QR code\",\"quiz-questions-tab-add-group-button\":\"Add group\",\"quiz-questions-tab-new-question-button\":\"New question\",\"button-label-edit\":\"edit\",\"quiz-questions-tab-empty-group-message\":\"No questions have been added to the group\",\"start-quiz-mode-button\":\"Start quiz\",\"freeze-quiz-button\":\"Stop quiz\",\"edit-quiz-button\":\"Edit quiz\",\"quiz-description\":\"Description\",\"number-of-participants\":\"Number of participants\",\"teachers\":\"Teachers\",\"quiz-allows-student-comments\":\"Allow participant comments in quiz mode\",\"quiz-allows-student-questions\":\"Allow participants to set questions\",\"quiz-student-participation\":\"Participation options\",\"quiz-allows-anonymous-participation\":\"Anonymous participation\",\"quiz-allows-participation-via-nickname\":\"Participation with pseudonym\",\"quiz-allows-participation-via-realname\":\"Participation with real name\",\"quiz-allowed-question-types\":\"Allowed question types\",\"quiz-allows-text-questions\":\"Allow free-text questions\",\"quiz-allows-single-choice-questions\":\"Allow single-choice questions\",\"quiz-allows-multiple-choice-realname\":\"Allow multiple-choice questions\",\"quiz-enable-question-shufflling\":\"Shuffle questions in quiz mode\",\"archive-quiz-button\":\"Archive quiz\",\"export-quiz-button\":\"Export data\",\"quiz-data-tab.button-tooltip.edit\":\"Edit\",\"quiz-data-tab.button-tooltip.share\":\"Share\",\"button-label-share\":\"Share\",\"export-quiz-statistics-button\":\"Export quiz statistics\",\"export-question-statistics-button\":\"Export question statistics\",\"no-data-yet\":\"No analyzable data is available yet\",\"details\":\"Details\",\"question-stats-prefix\":\"Question: \",\"question-stats-info\":[[\"participants\"],\" participants from \",[\"maxParticipants\"],\" (\",[\"ratio\"],\" %) have answered the question\"],\"question-stats-answers-given\":[[\"numberOfAnswers\"],\" answers were given\"],\"question-stats-given-answers\":\"The following answers were given\",\"question-stats-correct-answers\":[[\"passed\"],\" of \",[\"participants\"],\" answers were correct.\"],\"question-stats-answer-prefix\":\"Answer: \",\"question-stats-back-to-quiz-button\":\"Back to Quiz\",\"question-stats-previous-question-button\":\"Previous question\",\"question-stats-next-question-button\":\"Next question\",\"running-quiz-tab.button-tooltip.comment\":\"Comment\",\"quiz-card-last-change\":\"Last change\",\"quiz-card.button-tooltip.edit\":\"Edit\",\"quiz-card.button-tooltip.share\":\"Share\",\"quiz-card.button-tooltip.delete\":\"Delete\",\"button-new-quiz\":\"New quiz\",\"dashboard-tab-label-quizzes\":\"Quizzes\",\"dashboard-tab-label-users\":\"Users\",\"header-section.button-tooltip.edit-or-add-pseudonym\":\"Edit or add pseudonym\",\"header-section.button-tooltip.logout\":\"Logout\",\"header.logout\":\"Logout\",\"new-quiz\":\"New quiz\",\"quiz-title\":\"Title\",\"error-quiz-title-too-short\":\"Title is too short\",\"create-quiz-button\":\"Create quiz\",\"single-choice-selection\":\"Single choice\",\"multiple-choice-selection\":\"Multiple choice\",\"text-type-selection\":\"Free text\",\"question-edit.button-tooltip.check\":\"Select\",\"question-edit.button-tooltip.edit-title-text\":\"Edit\",\"question-edit.button-tooltip.edit-comment-text\":\"Edit comment\",\"question-edit.button-tooltip.edit-hint-title\":\"Edit title\",\"activate-all-correct-answers\":\"Activate all correct answers\",\"activate-correct-answer\":\"Activate the correct answer\",\"question-edit.button-tooltip.edit-answer\":\"Edit answer\",\"question-edit.button-tooltip.delete-answer\":\"Delete answer\",\"add-answer-button\":\"Add answer\",\"save-question-button\":\"Save question\",\"quiz-not-found-error-message\":\"Sorry, the quiz could not be opened\",\"back-to-dashboard-link\":\"Back to dashboard\",\"comment-row-new-comment-button\":\"New comment\",\"user-admin-panel-title\":\"User administration\",\"user-admin-panel.button-tooltip.filter\":\"Filter\",\"user-admin-panel-search-text\":\"Search text\",\"quiz-card-button-tooltip-edit\":\"Edit\",\"quiz-card-button-tooltip-share\":\"Share\",\"quiz-card-button-tooltip-delete\":\"Delete\",\"quiz-questioins-tab-add-group-button\":\"Add group\",\"quiz-tab-label-data\":\"Quiz data\",\"quiz-tab-label-questions\":\"Quiz questions\",\"quiz-tab-label-statistics\":\"Statistical evaluation\",\"edit-question-title\":\"Edit question\",\"edit-hint-title\":\"Edit hint text\",\"edit-answer-text\":\"Edit answer\",\"app.could_not_refresh_token\":\"Login could not be updated\",\"number-of-quiz-participants\":\"Number of participants\",\"teachers-in-quiz\":\"Teaching staff with access\",\"number-of-quiz-questions\":\"Number of questions\",\"error-message-no-server-connection\":\"No connection to the server could be established. Please log in again.\",\"error-message-no-server-connection-title\":\"No server connection\",\"error-message-user-deactivated\":\"Your user has been deactivated. Please contact your administrator.\",\"error-message-user-deactivated-title\":\"User deactivated\",\"archive-quiz-title\":\"Archive quiz\",\"archive-quiz-text\":\"Would you like to archive this quiz? It will no longer be displayed in the overview but can still be accessed via existing links and QR codes. Alternatively, you can delete the quiz permanently. Caution, there will be no further confirmation!\",\"title-set-quiz-mode-edit\":\"Start edit mode\",\"info-set-quiz-mode-edit\":\"Statistics are invalidated when editing mode is started. Start editing?\",\"title-set-quiz-mode-stopped\":\"Freeze quiz\",\"info-set-quiz-mode-stopped\":\"Do you wish to temporarily stop the quiz?\",\"warning-set-quiz-mode-started\":\"All statistics will be deleted when you start the quiz! Continue?\",\"info-set-quiz-mode-started\":\"Should the quiz mode be started?\",\"title-set-quiz-mode-started\":\"Start quiz mode\",\"add\":\"Add\",\"edit-title-text\":\"Edit text\",\"remove-edit-mode-of-question-title\":\"Reactivate edit mode?\",\"remove-edit-mode-of-question-text\":\"The question has been locked by another editing process. Would you like to reactivate the editing process?\",\"quiz-import-modal-title\":\"Import a quiz\",\"quiz-import-modal-message\":\"Select a quiz file to import\",\"import\":\"Import\",\"button-import-quiz\":\"Import quiz\",\"dashboard-show-archived-quizzes-switch\":\"Show archived quizzes\",\"delete-quiz-button\":\"Permanently delete quiz\",\"anonymous\":\"ANONYMOUS\",\"author\":\"Author\",\"new-comment-title\":\"New comment\",\"back-to-quiz-button\":\"Back to quiz\",\"question-card.button-tooltip.view\":\"Show question\",\"quiz-hide-comments-switch\":\"Hide comment list\",\"leave-quiz-modal-title\":\"Leave quiz\",\"leave-quiz-modal-text\":\"You are about to leave the quiz. It will no longer be displayed in your dashboard and you won't be able to modify it or participate. Really leave?\",\"quiz-card-teachers-label\":\"Teaching staff\",\"answer-correct-title\":\"Correct\",\"answer-correct\":\"Your answer is correct.\",\"answer-wrong-title\":\"Incorrect\",\"answer-wrong\":\"Sorry, your answer is incorrect.\",\"reset-stats-button\":\"Reset statistics\",\"title-reset-quiz\":\"Reset statistics\",\"info-reset-quiz\":\"Reset the statistics of all given answers?\",\"delete-question-title\":\"Delete question\",\"delete-question-text\":\"Delete question from quiz? This operation is irrevocable.\",\"question-card.button-tooltip.unapprove\":\"Revoke approval\",\"nickname-not-set\":\"Not set\",\"998VJr\":\"You\",\"dzcobz\":\"Your answer\"}")}; diff --git a/packages/frontend/src/locales/en/messages.po b/packages/frontend/src/locales/en/messages.po index eb3e37c..749ee16 100644 --- a/packages/frontend/src/locales/en/messages.po +++ b/packages/frontend/src/locales/en/messages.po @@ -37,7 +37,7 @@ msgstr "Anonymous" #. js-lingui-explicit-id #: src/layout/HeaderSection.tsx:88 msgid "participation-select-option.nickname" -msgstr "Nickname" +msgstr "Pseudonym" #. js-lingui-explicit-id #: src/layout/HeaderSection.tsx:88 @@ -81,6 +81,10 @@ msgstr "Participants can view statistics" msgid "quiz-data-tab.alert-message.create-new-question-is-disabled" msgstr "The 'New question' button is disabled" +#. js-lingui-explicit-id +msgid "quiz-data-tab.alert-message.participation-is-disabled" +msgstr "Submission of questions and comments is disabled for participants." + #. js-lingui-explicit-id #: src/components/quiz-tabs/QuestionsTab.tsx:236 msgid "quiz-questions-tab-new-question-button.button-tooltip.create-question-disabled" @@ -224,7 +228,7 @@ msgstr "User already selected" #. js-lingui-explicit-id #: src/components/modals/ShareQuizModal.tsx:229 msgid "share-quiz-modal.error-alert.do-not-exist" -msgstr "User doesn't exist" +msgstr "User not on RECAPP" #. js-lingui-explicit-id #: src/components/cards/CommentsContainer.tsx:44 @@ -497,10 +501,18 @@ msgstr "People to be added" msgid "share-quiz-modal.button-label.add" msgstr "Add" +#. js-lingui-explicit-id +msgid "share-quiz-modal.button-label.add-tooltip" +msgstr "Only teachers who have previously accessed RecApp are eligible to be added." + #. js-lingui-explicit-id #: src/components/modals/ShareQuizModal.tsx:93 msgid "share-with-confirmed-users" -msgstr "Share with confirmed users" +msgstr "Share quiz" + +#. js-lingui-explicit-id +msgid "share-with-confirmed-users-tooltip" +msgstr "Quiz appears in the dashboard of the added teachers." #. js-lingui-explicit-id #: src/components/quiz-tabs/QuestionsTab.tsx:191 @@ -592,7 +604,7 @@ msgstr "Allow participants to set questions" #: src/components/quiz-tabs/QuizDataTab.tsx:236 #: src/pages/CreateQuiz.tsx:98 msgid "quiz-student-participation" -msgstr "Participant participation options" +msgstr "Participation options" #. js-lingui-explicit-id #: src/components/quiz-tabs/QuizDataTab.tsx:239 @@ -774,6 +786,10 @@ msgstr "Edit" msgid "quiz-card.button-tooltip.start" msgstr "Start" +#. js-lingui-explicit-id +msgid "quiz-card.button-tooltip.stop" +msgstr "Stop" + #. js-lingui-explicit-id #: src/components/quizzes-panel/QuizCard.tsx:66 msgid "quiz-card.button-tooltip.share" @@ -874,6 +890,10 @@ msgstr "Edit title" msgid "activate-all-correct-answers" msgstr "Activate all correct answers" +#. js-lingui-explicit-id +msgid "activate-correct-answer" +msgstr "Activate the correct answer" + #. js-lingui-explicit-id #: src/pages/QuestionEdit.tsx:460 msgid "question-edit.button-tooltip.edit-answer" @@ -1307,8 +1327,52 @@ msgstr "Allow participants to create/edit questions" #. js-lingui-explicit-id msgid "quiz-button-label-end-preview" -msgstr "End preview" +msgstr "Teacher view" #. js-lingui-explicit-id msgid "quiz-button-label-start-preview" -msgstr "Start preview" +msgstr "Participant view" + +#. js-lingui-explicit-id +msgid "quiz-button-tooltip-preview" +msgstr "Starts quiz in participant view. Quiz stops when all joined teachers exit this mode." + +#. js-lingui-explicit-id +msgid "login-page.temporary-account-button" +msgstr "Continue without login" + +#. js-lingui-explicit-id +msgid "login-page.temp-login-header" +msgstr "Participate without logging in" + +#. js-lingui-explicit-id +msgid "login-page.temp-login-reminder" +msgstr "You can participate in the quiz without logging in. To prevent abuse, a so-called fingerprint is created for your browser and device, which allows us to identify you during your participation. By participating, you agree to this." + +#. js-lingui-explicit-id +msgid "login-page.store-cookie-checkbox" +msgstr "I want my login to be stored for 30 days, even if I close the browser window." + +#. js-lingui-explicit-id +msgid "login-page.info-text" +msgstr "You can login with your account or participate without logging in." + +#. js-lingui-explicit-id +msgid "login-page.account-deactivated-title" +msgstr "Login error. Account deactivated." + +#. js-lingui-explicit-id +msgid "login-page.account-deactivated-message" +msgstr "Your account was deactivated. Please contact the administrator." + +#. js-lingui-explicit-id +msgid "new-question" +msgstr "New question" + +#. js-lingui-explicit-id +msgid "question" +msgstr "Question" + +#. js-lingui-explicit-id +msgid "email-pseudonym-placeholder" +msgstr "Email or pseudonym" \ No newline at end of file diff --git a/packages/frontend/src/Dashboard.tsx b/packages/frontend/src/pages/Dashboard.tsx similarity index 76% rename from packages/frontend/src/Dashboard.tsx rename to packages/frontend/src/pages/Dashboard.tsx index 1bacf50..4f838f7 100644 --- a/packages/frontend/src/Dashboard.tsx +++ b/packages/frontend/src/pages/Dashboard.tsx @@ -1,14 +1,15 @@ import React, { useEffect } from "react"; -import { UserAdminPanel } from "./UserAdminPanel"; +import { UserAdminPanel } from "../UserAdminPanel"; +import { FingerprintPanel } from "../FingerprintPanel"; import { Tab, Tabs } from "react-bootstrap"; import { i18n } from "@lingui/core"; -import { QuizzesPanel } from "./components/quizzes-panel/QuizzesPanel"; +import { QuizzesPanel } from "../components/quizzes-panel/QuizzesPanel"; import { User } from "@recapp/models"; import { useStatefulActor } from "ts-actors-react"; -import { ErrorMessages } from "./actors/ErrorActor"; +import { ErrorMessages } from "../actors/ErrorActor"; import { useNavigate } from "react-router-dom"; -import { cookie } from "./utils"; -import { CurrentQuizMessages, CurrentQuizState } from "./actors/CurrentQuizActor"; +import { cookie } from "../utils"; +import { CurrentQuizMessages, CurrentQuizState } from "../actors/CurrentQuizActor"; // const tabClasses = "bg-content-container py-3"; const tabClasses = "py-3"; @@ -49,6 +50,11 @@ export const Dashboard: React.FC = () => { )} + {isAdmin && ( + + + + )} ); diff --git a/packages/frontend/src/pages/QuestionEdit.tsx b/packages/frontend/src/pages/QuestionEdit.tsx index 93013f2..636a92c 100644 --- a/packages/frontend/src/pages/QuestionEdit.tsx +++ b/packages/frontend/src/pages/QuestionEdit.tsx @@ -55,7 +55,7 @@ const sortComments = (a: Comment, b: Comment) => { export const QuestionEdit: React.FC = () => { const { state } = useLocation(); - const questionId = state?.quizId ?? ""; + const questionId = state?.questionId ?? ""; const [currentQuestionId, setCurrentQuestionId] = useState(questionId); const formerGroup = state?.group ?? ""; const writeAccess = state?.write === "true"; @@ -87,6 +87,11 @@ export const QuestionEdit: React.FC = () => { useEffect(() => { if (deleted) setShowError("quiz-error-quiz-deleted"); }, [deleted]); + useEffect(() => { + if (state.quiz && (mbQuiz.isEmpty() || mbQuiz.map(q => keys(q.quiz).length === 0))) { + tryQuizActor.forEach(a => a.send(a, CurrentQuizMessages.SetQuiz(state.quiz as Id))); + } + }, [state.quiz, mbQuiz]); const [defaultQuestion, setDefaultQuestion] = useState( { @@ -123,6 +128,7 @@ export const QuestionEdit: React.FC = () => { // const students = mbQuiz.flatMap(q => maybe(q.quiz)).flatMap(q => maybe(q.students)); // const isStudent = mbUser.map(u => students.map(s => s.includes(u.user.uid)).orElse(false)).orElse(false); const userId: Id = mbUser.flatMap(u => maybe(u.user?.uid)).orElse(toId("")); + const isTemporary = mbUser.flatMap(u => maybe(u.user?.isTemporary)).orElse(false); // const teachers = mbQuiz.flatMap(q => maybe(q.quiz)).flatMap(q => maybe(q.teachers)); // const isQuizTeacher = mbUser.map(u => teachers.map(s => s.includes(u.user.uid)).orElse(false)).orElse(false); @@ -132,8 +138,10 @@ export const QuestionEdit: React.FC = () => { const currentQuestionIndex = questionsIds.findIndex(x => x === currentQuestionId); const isLastQuestion = currentQuestionIndex >= questionsIds.length - 1; - const isUserInTeachersList = quiz && userId ? isInTeachersList(quiz.quiz, userId) : false; - const isUserInStudentsList = quiz && userId ? isInStudentList(quiz.quiz, userId) : true; + console.log("LOCATION state", state, "QUIZ", quiz); + + const isUserInTeachersList = quiz?.quiz && keys(quiz?.quiz).length > 0 && userId ? isInTeachersList(quiz.quiz, userId) : false; + const isUserInStudentsList = quiz?.quiz && keys(quiz?.quiz).length > 0 && userId ? isInStudentList(quiz.quiz, userId) : true; const isStudentCommentsAllowed = q?.studentComments; const showCommentSection = isUserInTeachersList || (isQuizStateStarted && isStudentCommentsAllowed); @@ -156,7 +164,7 @@ export const QuestionEdit: React.FC = () => { const aat: UserParticipation[] = keys(quiz?.quiz.studentParticipationSettings) .filter(k => !!quiz?.quiz.studentParticipationSettings[k as UserParticipation]) .map(k => k as UserParticipation); - setAllowedAuthorTypes(aat); + setAllowedAuthorTypes(isTemporary ? aat.filter(at => at !== "NAME") : aat); const aqt: QuestionType[] = keys(quiz?.quiz.allowedQuestionTypesSettings) .filter(k => !!quiz?.quiz.allowedQuestionTypesSettings[k as QuestionType]) @@ -596,7 +604,7 @@ export const QuestionEdit: React.FC = () => { > {mbQuiz.flatMap(q => maybe(q.quiz?.title)).orElse("---")} - {question.uid ? "Frage" : "Neue Frage"} + {question.uid ? i18n._("question") : i18n._("new-question")}
@@ -946,7 +954,10 @@ export const QuestionEdit: React.FC = () => {
- + + {question.type === "SINGLE" && } + {question.type === "MULTIPLE" && } +
{/* {isQuizTeacher && !shuffleAnswers ? ( */} {isActivateReorderAnswersVisible ? ( diff --git a/packages/frontend/src/pages/QuizPage.tsx b/packages/frontend/src/pages/QuizPage.tsx index bf7b9e5..c464dad 100644 --- a/packages/frontend/src/pages/QuizPage.tsx +++ b/packages/frontend/src/pages/QuizPage.tsx @@ -63,6 +63,7 @@ export const QuizPage: React.FC = () => { const quizId: Id = state?.quizId; const activate = state?.activate; const start: boolean = state?.start ?? false; + const stop: boolean = state?.stop ?? false; const system = useActorSystem(); const [mbLocalUser] = useStatefulActor<{ user: User }>("LocalUser"); const [mbQuiz, tryQuizActor] = useStatefulActor("CurrentQuiz"); @@ -107,6 +108,9 @@ export const QuizPage: React.FC = () => { if (start) { setTimeout(() => q.send(q, CurrentQuizMessages.ChangeState("STARTED")), 500); } + if (stop) { + setTimeout(() => q.send(q, CurrentQuizMessages.ChangeState("STOPPED")), 500); + } }); }); }, [quizId, tryQuizActor.hasValue]); @@ -254,6 +258,7 @@ export const QuizPage: React.FC = () => { const questionId = currentQuestion?.uid ?? toId(""); const disableForStudent = localUser.map(allowed).orElse(true); + const isTemporary = localUser.flatMap(u => maybe(u.isTemporary)).orElse(false); const isQuizTeacher = teachers.includes(localUser.map(u => u.uid).orElse(toId(""))) || localUser.map(u => u.role).is(r => r === "ADMIN"); @@ -305,6 +310,19 @@ export const QuizPage: React.FC = () => { ); }; + let participationOptions = + keys(quizData.quiz.studentParticipationSettings) + .filter(k => !!quizData.quiz.studentParticipationSettings[k as UserParticipation]) + .map(k => k as UserParticipation); + if (isTemporary) { + participationOptions = participationOptions.filter(p => p !== "NAME"); + if (participationOptions.length === 0) { + // TODO: Fehler anzeigen. Der Nutzer kann an dem Quiz nicht teilnehmen. + return +

Dieses Quiz hat Realnamenzwang. Melde dich bitte ab und mit deinem Uniaccount an.

+
; + } + } return ( { // isStudent={!isQuizTeacher} isUserInTeachersList={isUserInTeachersList} userNames={[userName, userNickname ?? ""]} - participationOptions={keys(quizData.quiz.studentParticipationSettings) - .filter(k => !!quizData.quiz.studentParticipationSettings[k as UserParticipation]) - .map(k => k as UserParticipation)} + participationOptions={participationOptions} /> {!quizData.isPresentationModeActive ? ( diff --git a/packages/frontend/src/utils.ts b/packages/frontend/src/utils.ts index 7516b9b..a4edf76 100644 --- a/packages/frontend/src/utils.ts +++ b/packages/frontend/src/utils.ts @@ -77,6 +77,14 @@ export const checkIsCreatingQuestionDisabled = (allowedQuestionTypesSettings: Qu return isCreatingQuestionEnabled; }; +export const checkIsParticipationDisabled = (studentParticipationSettings: Quiz["studentParticipationSettings"]) => { + const isParticipationEnabled = Object.values(studentParticipationSettings).every(value => { + return !value; + }); + + return isParticipationEnabled; +}; + export const downloadFile = async (filename: string)=> { return await axios .get(`${import.meta.env.VITE_BACKEND_URI}/download/${filename}`, { diff --git a/packages/models/src/data/quiz.ts b/packages/models/src/data/quiz.ts index c1306ff..c64cd46 100644 --- a/packages/models/src/data/quiz.ts +++ b/packages/models/src/data/quiz.ts @@ -19,6 +19,7 @@ export const questionSchema = zod text: zod.string(), // Question text type: questionTypesSchema, authorId: uidSchema, // Author id + authorFingerprint: zod.string().optional(), // Author id authorName: zod.string().optional(), // Display name of the author (may also be a nickname or ANONYMOUS) quiz: uidSchema, // Quiz the question belongs hint: zod.string().optional(), // Optional explanatory text/hint diff --git a/packages/models/src/data/session.ts b/packages/models/src/data/session.ts index 36ef95a..32b3568 100644 --- a/packages/models/src/data/session.ts +++ b/packages/models/src/data/session.ts @@ -2,6 +2,19 @@ import zod from "zod"; import { timestampSchema, uidSchema } from "./base"; import { userRoleSchema } from "./user"; +export const fingerprintSchema = zod.object({ + uid: uidSchema, // This is also the fingerprint string itself + created: timestampSchema, + updated: timestampSchema, + lastSeen: timestampSchema, + usageCount: zod.number().int(), + blocked: zod.boolean(), + userUid: uidSchema, + initialQuiz: uidSchema.optional() +}); + +export type Fingerprint = zod.infer; + export const sessionSchema = zod.object({ idToken: zod.string(), accessToken: zod.string(), @@ -13,6 +26,8 @@ export const sessionSchema = zod.object({ role: userRoleSchema, created: timestampSchema, updated: timestampSchema, + fingerprint: zod.string().optional(), // Fingerprint, is set if this is a temporary account + persistentCookie: zod.boolean().optional() // Whether this is a temporary account that is not deleted when closing the browser but stored in a persistentcookie }); export type Session = zod.infer; diff --git a/packages/models/src/data/user.ts b/packages/models/src/data/user.ts index 5fb58e8..079609d 100644 --- a/packages/models/src/data/user.ts +++ b/packages/models/src/data/user.ts @@ -23,6 +23,9 @@ export const userSchema = zod lastLogin: timestampSchema, // Date of last login active: zod.boolean(), // Whether the user can login quizUsage: zod.map(uidSchema, quizUsageType), // How the user decided to participate in each individual quiz + isTemporary: zod.boolean().optional().default(false), // Is this a temporary account + fingerprint: zod.string().optional(), // Fingerprint of a temp user for authentication purposes + initialQuiz: zod.string().optional(), // Initial quiz, if any (will be set if the user logged in or was created with a link) }) .merge(idEntitySchema); diff --git a/packages/models/src/index.ts b/packages/models/src/index.ts index 95f2f67..01a6239 100644 --- a/packages/models/src/index.ts +++ b/packages/models/src/index.ts @@ -5,6 +5,7 @@ export * from "./data/statistics"; export * from "./data/comment"; export * from "./data/session"; export * from "./messages/sessionStore"; +export * from "./messages/fingerprintStore"; export * from "./messages/userStore"; export * from "./messages/commentActor"; export * from "./messages/questionActor"; diff --git a/packages/models/src/messages/fingerprintStore.ts b/packages/models/src/messages/fingerprintStore.ts new file mode 100644 index 0000000..761a7e0 --- /dev/null +++ b/packages/models/src/messages/fingerprintStore.ts @@ -0,0 +1,23 @@ +import { unionize, ofType, UnionOf } from "unionize"; +import { Fingerprint } from "../data/session"; +import { Id } from "../data/base"; + +export const FingerprintStoreMessages = unionize( + { + StoreFingerprint: ofType & { uid: Id }>(), // Store the session; also removed older sessions of the user + Get: ofType(), // Get fingerprint info + Block: ofType(), // Block a fingerprint + Unblock: ofType(), // Unblock a fingerprint + IncreaseCount: ofType<{fingerprint: Id, userUid: Id, initialQuiz: Id | undefined}>(), // Increase usage count by one + GetMostRecent: {}, // Get the most recently seen fingerprints + SubscribeToCollection: {} + }, + { tag: "FingerprintStoreMessages", value: "value" } +); + +export type FingerprintStoreMessage = UnionOf; + +export class FingerprintUpdateMessage { + public readonly tag = "FingerprintUpdateMessage" as const; + constructor(public readonly fp: Partial) {} +} diff --git a/packages/models/src/messages/userStore.ts b/packages/models/src/messages/userStore.ts index c0b6a12..6e75944 100644 --- a/packages/models/src/messages/userStore.ts +++ b/packages/models/src/messages/userStore.ts @@ -12,6 +12,7 @@ export const UserStoreMessages = unionize( Get: ofType(), // Get user, answers with User GetOwn: {}, // Return the info of the requesting user, answers with User GetRole: ofType(), // Return the role of the given user, answers with UserRole + GetByFingerprint: ofType(), // Return a user for the given fingerprint GetNames: ofType>(), // Return the names for the given User Ids SubscribeTo: ofType(), // Subscribe to all changes of the specific user, sends back all updates to requester SubscribeToCollection: ofType(), // Subscribe to all changes of the specific user, sends back all updates to requester. Returns only the requested properties. @@ -19,6 +20,7 @@ export const UserStoreMessages = unionize( UnsubscribeFromCollection: {}, // Unsubscribe from collection changes GetTeachers: {}, // Returns a list of minimal information on all teachers (uid, name, pseudonym) IsNicknameUnique: ofType(), // Returns a boolean to signal whether the given pseudonym is unique. Note that you should not test for the user's own nickname since this will always be false + Remove: ofType(), // Removes a temporary user from the user store and db }, { tag: "UserStoreMessage", value: "value" } );