Add /api/website/allow endpoint
This commit is contained in:
parent
9aa8ee6105
commit
ca78ddc078
14 changed files with 305 additions and 126 deletions
|
@ -2,7 +2,9 @@
|
|||
<module type="WEB_MODULE" version="4">
|
||||
<component name="NewModuleRootManager" inherit-compiler-output="true">
|
||||
<exclude-output />
|
||||
<content url="file://$MODULE_DIR$" />
|
||||
<content url="file://$MODULE_DIR$">
|
||||
<excludeFolder url="file://$MODULE_DIR$/dist" />
|
||||
</content>
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
|
|
5
backend/package-lock.json
generated
5
backend/package-lock.json
generated
|
@ -1082,6 +1082,11 @@
|
|||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="
|
||||
},
|
||||
"base64url": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz",
|
||||
"integrity": "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A=="
|
||||
},
|
||||
"binary-extensions": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz",
|
||||
|
|
|
@ -20,6 +20,8 @@
|
|||
"@types/url-join": "^4.0.0",
|
||||
"@wulkanowy/sdk": "^0.1.1",
|
||||
"apollo-server-fastify": "^2.19.2",
|
||||
"base64url": "^3.0.1",
|
||||
"date-fns": "^2.16.1",
|
||||
"dotenv": "^8.2.0",
|
||||
"fastify": "^3.10.1",
|
||||
"fastify-cookie": "^5.1.0",
|
||||
|
|
52
backend/src/codes.ts
Normal file
52
backend/src/codes.ts
Normal file
|
@ -0,0 +1,52 @@
|
|||
import { addSeconds, isAfter } from 'date-fns';
|
||||
import type { FastifyLoggerInstance } from 'fastify';
|
||||
import { nanoid } from 'nanoid';
|
||||
import type { CodeChallenge, CodeContent, CodeInfo } from './types';
|
||||
import { createKey, encryptSymmetrical } from './utils';
|
||||
|
||||
const codes = new Map<string, CodeInfo>();
|
||||
|
||||
export function createCode(options: {
|
||||
clientId: string;
|
||||
scopes: string[];
|
||||
studentIds: number[];
|
||||
publicKey: string;
|
||||
tokenKey: string;
|
||||
encryptedPrivateKey: string;
|
||||
encryptedPassword: string;
|
||||
encryptedSDK: string;
|
||||
codeChallenge?: CodeChallenge
|
||||
}): string {
|
||||
const expires = addSeconds(new Date(), 60);
|
||||
const tokenSecret = createKey();
|
||||
let id: string;
|
||||
do {
|
||||
id = nanoid();
|
||||
} while (codes.has(id));
|
||||
codes.set(id, {
|
||||
expires,
|
||||
id,
|
||||
clientId: options.clientId,
|
||||
scopes: options.scopes,
|
||||
studentIds: options.studentIds,
|
||||
publicKey: options.publicKey,
|
||||
encryptedPrivateKey: options.encryptedPrivateKey,
|
||||
encryptedSDK: options.encryptedSDK,
|
||||
encryptedPassword: options.encryptedPassword,
|
||||
tokenSecret,
|
||||
codeChallenge: options.codeChallenge,
|
||||
});
|
||||
const content: CodeContent = {
|
||||
tk: options.tokenKey,
|
||||
};
|
||||
return `${id}~${encryptSymmetrical(JSON.stringify(content), tokenSecret)}`;
|
||||
}
|
||||
|
||||
export function cleanUpCodes(logger: FastifyLoggerInstance): void {
|
||||
codes.forEach((code) => {
|
||||
if (isAfter(new Date(), code.expires)) {
|
||||
codes.delete(code.id);
|
||||
logger.info(`Code ${code.id} expired`);
|
||||
}
|
||||
});
|
||||
}
|
|
@ -8,6 +8,7 @@ import FastifyCookie from 'fastify-cookie';
|
|||
import FastifyHttpProxy from 'fastify-http-proxy';
|
||||
import FastifySensible from 'fastify-sensible';
|
||||
import FastifySession from 'fastify-session';
|
||||
import { cleanUpCodes } from './codes';
|
||||
import { websitePrefix } from './constants';
|
||||
import database from './database/database';
|
||||
import registerOAuth from './routes/oauth2';
|
||||
|
@ -48,6 +49,10 @@ async function start() {
|
|||
const port = parseIntStrict(requireEnv('PORT'));
|
||||
await server.listen(port);
|
||||
server.log.info(`Listening on port ${port}`);
|
||||
|
||||
setInterval(() => {
|
||||
cleanUpCodes(server.log);
|
||||
}, 15000);
|
||||
}
|
||||
|
||||
start()
|
||||
|
|
|
@ -8,7 +8,7 @@ import type { MyFastifyInstance, StudentsMode } from '../../types';
|
|||
|
||||
import {
|
||||
createKey,
|
||||
getSessionData, isObject, parseScopeParam, validateOptionalParam, validateParam,
|
||||
getSessionData, isObject, parseArrayParam, validateOptionalParam, validateParam,
|
||||
} from '../../utils';
|
||||
|
||||
export default function registerAuthorize(server: MyFastifyInstance): void {
|
||||
|
@ -76,7 +76,7 @@ export default function registerAuthorize(server: MyFastifyInstance): void {
|
|||
}
|
||||
const studentsMode = request.query.students_mode as StudentsMode;
|
||||
|
||||
const requestedScopes = _.uniq(parseScopeParam('scope', request.query.scope));
|
||||
const requestedScopes = _.uniq(parseArrayParam('scope', request.query.scope));
|
||||
requestedScopes.forEach((scope) => {
|
||||
if (!scopes.includes(scope)) {
|
||||
throw new ScopeError(`Unknown scope ${scope}`);
|
||||
|
|
82
backend/src/routes/website-api/allow.ts
Normal file
82
backend/src/routes/website-api/allow.ts
Normal file
|
@ -0,0 +1,82 @@
|
|||
import { URL } from 'url';
|
||||
import { createCode } from '../../codes';
|
||||
import { ParamError } from '../../errors';
|
||||
import type { MyFastifyInstance, SerializedSDK } from '../../types';
|
||||
import { StudentsMode } from '../../types';
|
||||
import {
|
||||
decryptSymmetrical, encryptSymmetrical,
|
||||
getSessionData, isObject, parseArrayParam, parseIntStrict, validateParam,
|
||||
} from '../../utils';
|
||||
|
||||
export default function registerAllow(server: MyFastifyInstance): void {
|
||||
server.get('/allow', async (
|
||||
request,
|
||||
reply,
|
||||
) => {
|
||||
if (!isObject(request.query)) {
|
||||
server.log.warn('Request query is not an object');
|
||||
throw server.httpErrors.badRequest();
|
||||
}
|
||||
try {
|
||||
validateParam('prompt_id', request.query.prompt_id);
|
||||
} catch (error) {
|
||||
if (error instanceof ParamError) {
|
||||
throw server.httpErrors.badRequest(error.message);
|
||||
}
|
||||
server.log.error(error);
|
||||
throw server.httpErrors.internalServerError();
|
||||
}
|
||||
const encryptedTokenKey: string | undefined = request.cookies[`etk-${request.query.prompt_id}`];
|
||||
if (!encryptedTokenKey) throw server.httpErrors.badRequest('Missing etk cookie');
|
||||
|
||||
const sessionData = getSessionData(request.session);
|
||||
const prompt = sessionData.prompts.get(request.query.prompt_id);
|
||||
if (!prompt) throw server.httpErrors.badRequest('Prompt data not found');
|
||||
if (!prompt.loginInfo) throw server.httpErrors.badRequest('Login data not provided');
|
||||
|
||||
const tokenKey = decryptSymmetrical(encryptedTokenKey, prompt.promptSecret);
|
||||
const serializedSDK = JSON.parse(decryptSymmetrical(prompt.loginInfo.encryptedSDK, tokenKey)) as SerializedSDK;
|
||||
|
||||
let studentIds: number[] = [];
|
||||
if (prompt.studentsMode !== StudentsMode.None) {
|
||||
try {
|
||||
studentIds = parseArrayParam('student_ids', request.query.student_ids).map(parseIntStrict);
|
||||
if (studentIds.length === 0) throw new ParamError('student_ids should not be empty');
|
||||
} catch (error) {
|
||||
if (error instanceof ParamError) {
|
||||
throw server.httpErrors.badRequest(error.message);
|
||||
}
|
||||
server.log.error(error);
|
||||
throw server.httpErrors.internalServerError();
|
||||
}
|
||||
}
|
||||
|
||||
const newSerializedSDK: SerializedSDK = {
|
||||
client: serializedSDK.client,
|
||||
diaries: serializedSDK.diaries.filter((diary) => studentIds.includes(diary.info.studentId)),
|
||||
};
|
||||
const newEncryptedSDK = encryptSymmetrical(JSON.stringify(newSerializedSDK), tokenKey);
|
||||
|
||||
const code = createCode({
|
||||
studentIds,
|
||||
scopes: prompt.scopes,
|
||||
clientId: prompt.clientId,
|
||||
publicKey: prompt.loginInfo.publicKey,
|
||||
encryptedSDK: newEncryptedSDK,
|
||||
encryptedPassword: prompt.loginInfo.encryptedPassword,
|
||||
encryptedPrivateKey: prompt.loginInfo.encryptedPrivateKey,
|
||||
tokenKey,
|
||||
});
|
||||
|
||||
// TODO: Find why the promise never resolves
|
||||
reply.clearCookie(`etk-${request.query.prompt_id}`);
|
||||
// In case execution of setCookie takes some time
|
||||
// TODO: Remove
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
const redirectUri = new URL(prompt.redirectUri);
|
||||
redirectUri.searchParams.set('code', code);
|
||||
if (prompt.state) redirectUri.searchParams.set('state', prompt.state);
|
||||
await reply.redirect(redirectUri.toString());
|
||||
sessionData.prompts.delete(request.query.prompt_id);
|
||||
});
|
||||
}
|
35
backend/src/routes/website-api/deny.ts
Normal file
35
backend/src/routes/website-api/deny.ts
Normal file
|
@ -0,0 +1,35 @@
|
|||
import { ParamError } from '../../errors';
|
||||
import type { MyFastifyInstance } from '../../types';
|
||||
import { getSessionData, isObject, validateParam } from '../../utils';
|
||||
|
||||
export default function registerDeny(server: MyFastifyInstance): void {
|
||||
server.get('/deny', async (
|
||||
request,
|
||||
reply,
|
||||
) => {
|
||||
if (!isObject(request.query)) {
|
||||
server.log.warn('Request query is not an object');
|
||||
throw server.httpErrors.badRequest();
|
||||
}
|
||||
try {
|
||||
validateParam('prompt_id', request.query.prompt_id);
|
||||
} catch (error) {
|
||||
if (error instanceof ParamError) {
|
||||
throw server.httpErrors.badRequest(error.message);
|
||||
}
|
||||
server.log.error(error);
|
||||
throw server.httpErrors.internalServerError();
|
||||
}
|
||||
// TODO: Find why the promise never resolves
|
||||
reply.clearCookie(`epk-${request.query.prompt_id}`);
|
||||
// In case execution of setCookie takes some time
|
||||
// TODO: Remove
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
const sessionData = getSessionData(request.session);
|
||||
const prompt = sessionData.prompts.get(request.query.prompt_id);
|
||||
if (!prompt) throw server.httpErrors.badRequest('Prompt data not found');
|
||||
await reply.redirect(`${prompt.redirectUri}?error=access_denied&error_description=${encodeURIComponent('User denied')}`);
|
||||
sessionData.prompts.delete(request.query.prompt_id);
|
||||
});
|
||||
}
|
|
@ -1,8 +1,9 @@
|
|||
import { ApolloServer } from 'apollo-server-fastify';
|
||||
import { buildSchema } from 'type-graphql';
|
||||
import { ParamError } from '../../errors';
|
||||
import type { ApolloContext, MyFastifyInstance } from '../../types';
|
||||
import { getSessionData, isObject, validateParam } from '../../utils';
|
||||
import { getSessionData } from '../../utils';
|
||||
import registerAllow from './allow';
|
||||
import registerDeny from './deny';
|
||||
import LoginResolver from './resolvers/login-resolver';
|
||||
import PromptInfoResolver from './resolvers/prompt-info-resolver';
|
||||
import type { WebsiteAPIContext } from './types';
|
||||
|
@ -28,33 +29,6 @@ export default async function registerWebsiteApi(server: MyFastifyInstance): Pro
|
|||
},
|
||||
}));
|
||||
|
||||
server.get('/deny', async (
|
||||
request,
|
||||
reply,
|
||||
) => {
|
||||
if (!isObject(request.query)) {
|
||||
server.log.warn('Request query is not an object');
|
||||
throw server.httpErrors.badRequest();
|
||||
}
|
||||
try {
|
||||
validateParam('prompt_id', request.query.prompt_id);
|
||||
} catch (error) {
|
||||
if (error instanceof ParamError) {
|
||||
throw server.httpErrors.badRequest(error.message);
|
||||
}
|
||||
server.log.error(error);
|
||||
throw server.httpErrors.internalServerError();
|
||||
}
|
||||
// TODO: Find why the promise never resolves
|
||||
reply.clearCookie(`epk-${request.query.prompt_id}`);
|
||||
// In case execution of setCookie takes some time
|
||||
// TODO: Remove
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
const sessionData = getSessionData(request.session);
|
||||
const prompt = sessionData.prompts.get(request.query.prompt_id);
|
||||
if (!prompt) throw server.httpErrors.badRequest('Prompt data not found');
|
||||
await reply.redirect(`${prompt.redirectUri}?error=access_denied&error_description=${encodeURIComponent('User denied')}`);
|
||||
sessionData.prompts.delete(request.query.prompt_id);
|
||||
});
|
||||
registerDeny(server);
|
||||
registerAllow(server);
|
||||
}
|
||||
|
|
|
@ -7,6 +7,7 @@ import {
|
|||
} from 'type-graphql';
|
||||
import type { SerializedSDK } from '../../../types';
|
||||
import {
|
||||
createKey,
|
||||
encryptSymmetrical, encryptWithPublicKey, generatePrivatePublicPair, isObject, verifyCaptchaResponse,
|
||||
} from '../../../utils';
|
||||
import { CaptchaError, InvalidVulcanCredentialsError, UnknownPromptError } from '../errors';
|
||||
|
@ -39,12 +40,6 @@ export default class LoginResolver {
|
|||
throw error;
|
||||
}
|
||||
const diaryList = await client.getDiaryList();
|
||||
const { privateKey, publicKey } = await generatePrivatePublicPair();
|
||||
const encryptedPrivateKey = encryptSymmetrical(
|
||||
privateKey,
|
||||
prompt.promptSecret,
|
||||
);
|
||||
const encryptedPassword = encryptWithPublicKey(password, publicKey);
|
||||
const diaryStudents = _.groupBy(diaryList.map((e) => e.serialized.info), 'studentId');
|
||||
const students = _.toPairs(diaryStudents)
|
||||
.map(([, diaryInfoList]: [string, DiaryInfo[]]) => diaryInfoList[0])
|
||||
|
@ -56,9 +51,17 @@ export default class LoginResolver {
|
|||
client: client.serialize(),
|
||||
diaries: diaryList.map(({ serialized }) => serialized),
|
||||
};
|
||||
const encryptedSDK = encryptWithPublicKey(JSON.stringify(serializedSDK), publicKey);
|
||||
|
||||
const { privateKey, publicKey } = await generatePrivatePublicPair();
|
||||
const tokenKey = createKey();
|
||||
const encryptedSDK = encryptSymmetrical(JSON.stringify(serializedSDK), tokenKey);
|
||||
const encryptedPassword = encryptWithPublicKey(password, publicKey);
|
||||
const encryptedPrivateKey = encryptSymmetrical(privateKey, tokenKey);
|
||||
const encryptedTokenKey = encryptSymmetrical(tokenKey, prompt.promptSecret);
|
||||
|
||||
prompt.loginInfo = {
|
||||
encryptedPassword,
|
||||
encryptedPrivateKey,
|
||||
encryptedSDK,
|
||||
publicKey,
|
||||
host,
|
||||
|
@ -66,7 +69,7 @@ export default class LoginResolver {
|
|||
availableStudentIds: students.map(({ studentId }) => studentId),
|
||||
};
|
||||
// TODO: Find why the promise never resolves
|
||||
reply.setCookie(`epk-${promptId}`, encryptedPrivateKey, {
|
||||
reply.setCookie(`etk-${promptId}`, encryptedTokenKey, {
|
||||
sameSite: 'strict',
|
||||
httpOnly: true,
|
||||
path: '/',
|
||||
|
|
|
@ -21,22 +21,25 @@ registerEnumType(StudentsMode, {
|
|||
name: 'StudentsMode',
|
||||
});
|
||||
|
||||
export interface CodeChallenge {
|
||||
value: string;
|
||||
method: 'plain' | 'S256';
|
||||
}
|
||||
|
||||
export interface Prompt {
|
||||
clientId: string;
|
||||
redirectUri: string;
|
||||
scopes: string[],
|
||||
state?: string;
|
||||
codeChallenge?: {
|
||||
value: string;
|
||||
method: 'plain' | 'S256';
|
||||
};
|
||||
codeChallenge?: CodeChallenge;
|
||||
studentsMode: StudentsMode;
|
||||
promptSecret: Buffer;
|
||||
promptSecret: string;
|
||||
loginInfo?: {
|
||||
host: string;
|
||||
username: string;
|
||||
encryptedPassword: string;
|
||||
encryptedSDK: string;
|
||||
encryptedPrivateKey: string;
|
||||
publicKey: string;
|
||||
availableStudentIds: number[];
|
||||
};
|
||||
|
@ -61,3 +64,21 @@ export interface ApolloContext {
|
|||
request: FastifyRequest;
|
||||
reply: FastifyReply,
|
||||
}
|
||||
|
||||
export interface CodeInfo {
|
||||
id: string;
|
||||
expires: Date;
|
||||
scopes: string[];
|
||||
clientId: string;
|
||||
studentIds: number[];
|
||||
tokenSecret: string;
|
||||
publicKey: string;
|
||||
encryptedPassword: string;
|
||||
encryptedSDK: string;
|
||||
encryptedPrivateKey: string;
|
||||
codeChallenge?: CodeChallenge;
|
||||
}
|
||||
|
||||
export interface CodeContent {
|
||||
tk: string;
|
||||
}
|
||||
|
|
|
@ -1,12 +1,13 @@
|
|||
import * as crypto from 'crypto';
|
||||
import * as util from 'util';
|
||||
import base64url from 'base64url';
|
||||
|
||||
export function generatePrivatePublicPair(): Promise<{
|
||||
privateKey: string;
|
||||
publicKey: string;
|
||||
}> {
|
||||
return util.promisify(crypto.generateKeyPair)('rsa', {
|
||||
modulusLength: 1024,
|
||||
modulusLength: 2048,
|
||||
publicKeyEncoding: {
|
||||
type: 'spki',
|
||||
format: 'pem',
|
||||
|
@ -18,30 +19,30 @@ export function generatePrivatePublicPair(): Promise<{
|
|||
});
|
||||
}
|
||||
|
||||
export function createKey(): Buffer {
|
||||
return crypto.randomBytes(32);
|
||||
export function createKey(): string {
|
||||
return base64url.encode(crypto.randomBytes(32));
|
||||
}
|
||||
|
||||
export function encryptSymmetrical(value: string, key: Buffer): string {
|
||||
export function encryptSymmetrical(value: string, key: string): string {
|
||||
const ivBuffer = crypto.randomBytes(16);
|
||||
const cipher = crypto.createCipheriv('aes-256-cbc', key, ivBuffer);
|
||||
const cipher = crypto.createCipheriv('aes-256-cbc', base64url.toBuffer(key), ivBuffer);
|
||||
const encrypted = Buffer.concat([cipher.update(value), cipher.final()]);
|
||||
return `${encrypted.toString('base64')}@${ivBuffer.toString('base64')}`;
|
||||
return `${base64url.encode(encrypted)}@${base64url.encode(ivBuffer)}`;
|
||||
}
|
||||
|
||||
export function decryptSymmetrical(encrypted: string, key: Buffer): string {
|
||||
const [iv, encryptedData] = encrypted.split('@');
|
||||
const ivBuffer = Buffer.from(iv, 'base64');
|
||||
const encryptedBuffer = Buffer.from(encryptedData, 'base64');
|
||||
const decipher = crypto.createDecipheriv('aes-256-cbc', key, ivBuffer);
|
||||
export function decryptSymmetrical(encrypted: string, key: string): string {
|
||||
const [encryptedData, iv] = encrypted.split('@');
|
||||
const ivBuffer = base64url.toBuffer(iv);
|
||||
const encryptedBuffer = base64url.toBuffer(encryptedData);
|
||||
const decipher = crypto.createDecipheriv('aes-256-cbc', base64url.toBuffer(key), ivBuffer);
|
||||
const decrypted = Buffer.concat([decipher.update(encryptedBuffer), decipher.final()]);
|
||||
return decrypted.toString();
|
||||
}
|
||||
|
||||
export function encryptWithPublicKey(value: string, publicKey: string): string {
|
||||
return crypto.publicEncrypt(publicKey, Buffer.from(value)).toString('base64');
|
||||
return base64url.encode(crypto.publicEncrypt(publicKey, Buffer.from(value)));
|
||||
}
|
||||
|
||||
export function decryptWithPrivateKey(encrypted: string, privateKey: string): string {
|
||||
return crypto.privateDecrypt(privateKey, Buffer.from(encrypted, 'base64')).toString();
|
||||
return crypto.privateDecrypt(privateKey, base64url.toBuffer(encrypted)).toString();
|
||||
}
|
||||
|
|
|
@ -18,8 +18,8 @@ export function requireEnvBase64(name: string): Buffer {
|
|||
return Buffer.from(requireEnv(name), 'base64');
|
||||
}
|
||||
|
||||
export function parseIntStrict(value: string, radix = 10): number {
|
||||
const number = parseInt(value, radix);
|
||||
export function parseIntStrict(value: string): number {
|
||||
const number = parseInt(value, 10);
|
||||
if (_.isNaN(number)) throw new Error(`Cannot parse ${value} to int`);
|
||||
return number;
|
||||
}
|
||||
|
@ -44,13 +44,13 @@ export function validateOptionalParam(key: string, value: unknown): asserts valu
|
|||
if (typeof value !== 'string') throw new ParamError(`${key} param should be a string`);
|
||||
}
|
||||
|
||||
export function parseScopeParam(key: string, value: unknown): string[] {
|
||||
export function parseArrayParam(key: string, value: unknown): string[] {
|
||||
if (value === undefined) throw new ParamError(`${key} param is missing`);
|
||||
if (typeof value === 'string') {
|
||||
if (value === '') return [];
|
||||
return value.split('+').map((scope) => scope.trim());
|
||||
return value.split(/[+ ]/g).map((item) => item.trim());
|
||||
}
|
||||
if (_.isArray(value)) return value.flatMap((e) => parseScopeParam(key, e));
|
||||
if (_.isArray(value)) return value.flatMap((e) => parseArrayParam(key, e));
|
||||
throw new ParamError(`${key} param should be a string or an array of strings`);
|
||||
}
|
||||
|
||||
|
@ -75,7 +75,6 @@ export async function verifyCaptchaResponse(response: string): Promise<boolean>
|
|||
response,
|
||||
},
|
||||
});
|
||||
console.log(body);
|
||||
return body.success;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
<template>
|
||||
<div>
|
||||
<v-form @submit.prevent="submit">
|
||||
<v-card-title
|
||||
v-if="mode === StudentsMode.One"
|
||||
>
|
||||
|
@ -59,7 +58,6 @@
|
|||
Przydziel dostęp
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
@ -113,7 +111,7 @@ export default class StudentsWindow extends Vue {
|
|||
|
||||
get allowUrl() {
|
||||
if (!this.valid) return null;
|
||||
if (this.mode === StudentsMode.One) return `/api/website/allow?prompt_id=${this.promptInfo.id}`;
|
||||
if (this.mode === StudentsMode.None) return `/api/website/allow?prompt_id=${this.promptInfo.id}`;
|
||||
const studentIds = this.pickedStudents.map(encodeURIComponent).join('+');
|
||||
return `/api/website/allow?prompt_id=${this.promptInfo.id}&student_ids=${studentIds}`;
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue