Add symbol picker and user creation

This commit is contained in:
Dominik Korsa 2021-01-25 13:21:10 +01:00
parent 44a279bc58
commit ecb9ab21fd
No known key found for this signature in database
GPG key ID: 546F986F71A6FE6E
35 changed files with 754 additions and 109 deletions

View file

@ -33,6 +33,9 @@
"@typescript-eslint/consistent-type-imports": ["error", {
"prefer": "type-imports",
"disallowTypeAnnotations": true
}],
"no-underscore-dangle": ["error", {
"allow": ["_id"]
}]
}
}

View file

@ -629,15 +629,16 @@
}
},
"@wulkanowy/sdk": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/@wulkanowy/sdk/-/sdk-0.1.1.tgz",
"integrity": "sha512-ktBYmd4nNeAs5STH9rf9EaAxPxSCN2PWtuP+rqW980RcWIv6IJddYvW1j8u60VFoThxMPWMpRHr7MeRqjKaC4A==",
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/@wulkanowy/sdk/-/sdk-0.1.3.tgz",
"integrity": "sha512-Ki2ZsyudbbmHr12/sne0Ssjj+kHxf2AUbUJEbeTgZbJUkocAk/rI0UaqjdnZ06uLGruC9FuKt+H1AuIoAbPcxQ==",
"requires": {
"axios": "^0.21.1",
"axios-cookiejar-support": "^1.0.1",
"cheerio": "github:dominik-korsa/cheerio#export-types",
"date-fns": "^2.16.1",
"date-fns-tz": "^1.0.12",
"lodash": "^4.17.20",
"querystring": "^0.2.0",
"tough-cookie": "^4.0.0"
}
@ -1662,9 +1663,9 @@
}
},
"entities": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-2.1.0.tgz",
"integrity": "sha512-hCx1oky9PFrJ611mf0ifBLBRW8lUUVRlFolb5gWRfIELabBlbp9xZvrqZLZAs+NxFnbfQoeGd8wDkygjg7U85w=="
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz",
"integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A=="
},
"error-ex": {
"version": "1.3.2",

View file

@ -18,7 +18,7 @@
"@types/lodash": "^4.14.167",
"@types/node": "^14.14.21",
"@types/url-join": "^4.0.0",
"@wulkanowy/sdk": "^0.1.1",
"@wulkanowy/sdk": "^0.1.3",
"apollo-server-fastify": "^2.19.2",
"base64url": "^3.0.1",
"date-fns": "^2.16.1",

View file

@ -1,6 +1,7 @@
import { addSeconds, isAfter } from 'date-fns';
import type { FastifyLoggerInstance } from 'fastify';
import { nanoid } from 'nanoid';
import type { ObjectID } from 'typeorm';
import { UnknownCodeError } from './errors';
import type { CodeChallenge, CodeContent, CodeInfo } from './types';
import { createKey, decryptSymmetrical, encryptSymmetrical } from './utils';
@ -11,6 +12,7 @@ export function createCode(options: {
clientId: string;
scopes: string[];
studentIds: number[];
userId: ObjectID;
publicKey: string;
tokenKey: string;
encryptedPrivateKey: string;
@ -29,6 +31,7 @@ export function createCode(options: {
expires,
id,
clientId: options.clientId,
userId: options.userId,
scopes: options.scopes,
studentIds: options.studentIds,
publicKey: options.publicKey,

View file

@ -0,0 +1,29 @@
import type { ObjectID } from 'typeorm';
import {
BaseEntity, Column, Entity, ObjectIdColumn,
} from 'typeorm';
@Entity()
export default class User extends BaseEntity {
@ObjectIdColumn()
public _id!: ObjectID;
@Column()
public username!: string;
@Column()
public host!: string;
@Column()
public symbol!: string;
@Column()
public email!: string;
@Column()
public loginIds!: string[];
public static getLoginId(senderId: number, unitId: number): string {
return `${senderId}@${unitId}`;
}
}

View file

@ -66,7 +66,9 @@ export default function registerToken(server: MyFastifyInstance): void {
}
const application = await database.applicationRepo.findOne({
clientId: request.body.client_id,
where: {
clientId: request.body.client_id,
},
});
if (!application) {
await sendCustomError(reply, 'invalid_client', 'Application not found', 401);
@ -98,7 +100,7 @@ export default function registerToken(server: MyFastifyInstance): void {
}
}
// TODO: Generate and return code;
// TODO: Generate and return token;
invalidateCode(codeInfo.id);
await reply.code(500).send('Not implemented');

View file

@ -1,4 +1,6 @@
import { URL } from 'url';
import type { SerializedClient } from '@wulkanowy/sdk/dist/diary/interfaces/serialized-client';
import type { SerializedDiary } from '@wulkanowy/sdk/dist/diary/interfaces/serialized-diary';
import { createCode } from '../../codes';
import { ParamError } from '../../errors';
import type { MyFastifyInstance, SerializedSDK } from '../../types';
@ -33,9 +35,12 @@ export default function registerAllow(server: MyFastifyInstance): void {
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');
if (!prompt.loginInfo.symbolInfo) throw server.httpErrors.badRequest('Symbol not provided');
if (!prompt.loginInfo.symbolInfo.userId) throw server.httpErrors.badRequest('User not registered');
const tokenKey = decryptSymmetrical(encryptedTokenKey, prompt.promptSecret);
const serializedSDK = JSON.parse(decryptSymmetrical(prompt.loginInfo.encryptedSDK, tokenKey)) as SerializedSDK;
const serializedClient = JSON.parse(decryptSymmetrical(prompt.loginInfo.encryptedClient, tokenKey)) as SerializedClient;
const serializedDiaries = JSON.parse(decryptSymmetrical(prompt.loginInfo.symbolInfo.encryptedDiaries, tokenKey)) as SerializedDiary[];
let studentIds: number[] = [];
if (prompt.studentsMode !== StudentsMode.None) {
@ -50,10 +55,11 @@ export default function registerAllow(server: MyFastifyInstance): void {
throw server.httpErrors.internalServerError();
}
}
// TODO: Verify studentIds with availableStudentIds
const newSerializedSDK: SerializedSDK = {
client: serializedSDK.client,
diaries: serializedSDK.diaries.filter((diary) => studentIds.includes(diary.info.studentId)),
client: serializedClient,
diaries: serializedDiaries.filter((diary) => studentIds.includes(diary.info.studentId)),
};
const newEncryptedSDK = encryptSymmetrical(JSON.stringify(newSerializedSDK), tokenKey);
@ -61,6 +67,7 @@ export default function registerAllow(server: MyFastifyInstance): void {
studentIds,
scopes: prompt.scopes,
clientId: prompt.clientId,
userId: prompt.loginInfo.symbolInfo.userId,
publicKey: prompt.loginInfo.publicKey,
encryptedSDK: newEncryptedSDK,
encryptedPassword: prompt.loginInfo.encryptedPassword,

View file

@ -23,3 +23,11 @@ export class InvalidVulcanCredentialsError extends ApolloError {
super('Invalid vulcan credentials', 'INVALID_VULCAN_CREDENTIALS');
}
}
export class InvalidSymbolError extends ApolloError {
public name = 'InvalidSymbolError';
public constructor() {
super('Invalid symbol', 'INVALID_SYMBOL');
}
}

View file

@ -4,8 +4,10 @@ import type { ApolloContext, MyFastifyInstance } from '../../types';
import { getSessionData } from '../../utils';
import registerAllow from './allow';
import registerDeny from './deny';
import CreateUserResolver from './resolvers/create-user-resolver';
import LoginResolver from './resolvers/login-resolver';
import PromptInfoResolver from './resolvers/prompt-info-resolver';
import SetSymbolResolver from './resolvers/set-symbol-resolver';
import type { WebsiteAPIContext } from './types';
export default async function registerWebsiteApi(server: MyFastifyInstance): Promise<void> {
@ -14,6 +16,8 @@ export default async function registerWebsiteApi(server: MyFastifyInstance): Pro
resolvers: [
PromptInfoResolver,
LoginResolver,
SetSymbolResolver,
CreateUserResolver,
],
});
const apolloServer = new ApolloServer({

View file

@ -0,0 +1,7 @@
import { Field, ObjectType } from 'type-graphql';
@ObjectType()
export default class CreateUserResult {
@Field(() => Boolean)
public success!: true; // GraphQL doesn't allow empty result
}

View file

@ -1,8 +1,7 @@
import { Field, ObjectType } from 'type-graphql';
import LoginResultStudent from './login-result-student';
@ObjectType()
export default class LoginResult {
@Field(() => [LoginResultStudent])
public students!: LoginResultStudent[];
@Field(() => [String])
public symbols!: string[];
}

View file

@ -1,7 +1,7 @@
import { Field, Int, ObjectType } from 'type-graphql';
@ObjectType()
export default class LoginResultStudent {
export default class LoginStudent {
@Field(() => Int)
public studentId!: number;

View file

@ -0,0 +1,11 @@
import { Field, ObjectType } from 'type-graphql';
import LoginStudent from './login-student';
@ObjectType()
export default class SetSymbolResult {
@Field(() => [LoginStudent])
public students!: LoginStudent[];
@Field(() => Boolean)
public registered!: boolean;
}

View file

@ -0,0 +1,48 @@
/* eslint-disable class-methods-use-this */
import { UserInputError } from 'apollo-server-fastify';
import {
Arg, Ctx, Mutation, Resolver,
} from 'type-graphql';
import database from '../../../database/database';
import User from '../../../database/entities/user';
import { UnknownPromptError } from '../errors';
import CreateUserResult from '../models/create-user-result';
import type { WebsiteAPIContext } from '../types';
@Resolver()
export default class CreateUserResolver {
@Mutation(() => CreateUserResult)
public async createUser(
@Arg('promptId') promptId: string,
@Arg('email') email: string,
@Ctx() { sessionData }: WebsiteAPIContext,
): Promise<CreateUserResult> {
if (email !== email.trim()) throw new UserInputError('Email should be trimmed');
const prompt = sessionData.prompts.get(promptId);
if (!prompt) throw new UnknownPromptError();
if (!prompt.loginInfo) throw new UserInputError('Login data not provided');
if (!prompt.loginInfo.symbolInfo) throw new UserInputError('Symbol not provided');
const existingUser = await database.userRepo.findOne({
where: {
host: prompt.loginInfo.host,
symbol: prompt.loginInfo.symbolInfo.symbol,
loginIds: {
$in: prompt.loginInfo.symbolInfo.loginIds,
},
},
});
if (existingUser !== undefined) throw new UserInputError('User already exists');
const user = new User();
user.host = prompt.loginInfo.host;
user.symbol = prompt.loginInfo.symbolInfo.symbol;
user.username = prompt.loginInfo.username;
user.loginIds = prompt.loginInfo.symbolInfo.loginIds;
user.email = email;
await database.userRepo.save(user);
prompt.loginInfo.symbolInfo.userId = user._id;
return {
success: true,
};
}
}

View file

@ -1,18 +1,15 @@
/* eslint-disable class-methods-use-this */
import { Client } from '@wulkanowy/sdk';
import type { DiaryInfo } from '@wulkanowy/sdk/dist/diary/interfaces/diary/diary-info';
import _ from 'lodash';
import { UserInputError } from 'apollo-server-fastify';
import {
Arg, Ctx, Mutation, Resolver,
} from 'type-graphql';
import type { SerializedSDK } from '../../../types';
import {
createKey,
encryptSymmetrical, encryptWithPublicKey, generatePrivatePublicPair, isObject, verifyCaptchaResponse,
} from '../../../utils';
import { CaptchaError, InvalidVulcanCredentialsError, UnknownPromptError } from '../errors';
import LoginResult from '../models/login-result';
import type LoginResultStudent from '../models/login-result-student';
import type { WebsiteAPIContext } from '../types';
@Resolver()
@ -26,6 +23,8 @@ export default class LoginResolver {
@Arg('captchaResponse') captchaResponse: string,
@Ctx() { sessionData, reply }: WebsiteAPIContext,
): Promise<LoginResult> {
if (username !== username.trim()) throw new UserInputError('Username should be trimmed');
if (host !== host.trim()) throw new UserInputError('Host should be trimmed');
const prompt = sessionData.prompts.get(promptId);
if (!prompt) throw new UnknownPromptError();
if (!await verifyCaptchaResponse(captchaResponse)) throw new CaptchaError();
@ -33,28 +32,17 @@ export default class LoginResolver {
username,
password,
}));
let symbols: string[];
try {
await client.login();
symbols = await client.login();
} catch (error) {
if (isObject(error) && error.name === 'InvalidCredentialsError') throw new InvalidVulcanCredentialsError();
throw error;
}
const diaryList = await client.getDiaryList();
const diaryStudents = _.groupBy(diaryList.map((e) => e.serialized.info), 'studentId');
const students = _.toPairs(diaryStudents)
.map(([, diaryInfoList]: [string, DiaryInfo[]]) => diaryInfoList[0])
.map<LoginResultStudent>((diaryInfo) => ({
name: `${diaryInfo.studentFirstName} ${diaryInfo.studentSurname}`,
studentId: diaryInfo.studentId,
}));
const serializedSDK: SerializedSDK = {
client: client.serialize(),
diaries: diaryList.map(({ serialized }) => serialized),
};
const { privateKey, publicKey } = await generatePrivatePublicPair();
const tokenKey = createKey();
const encryptedSDK = encryptSymmetrical(JSON.stringify(serializedSDK), tokenKey);
const encryptedClient = encryptSymmetrical(JSON.stringify(client.serialize()), tokenKey);
const encryptedPassword = encryptWithPublicKey(password, publicKey);
const encryptedPrivateKey = encryptSymmetrical(privateKey, tokenKey);
const encryptedTokenKey = encryptSymmetrical(tokenKey, prompt.promptSecret);
@ -62,11 +50,10 @@ export default class LoginResolver {
prompt.loginInfo = {
encryptedPassword,
encryptedPrivateKey,
encryptedSDK,
encryptedClient,
publicKey,
host,
username,
availableStudentIds: students.map(({ studentId }) => studentId),
};
// TODO: Find why the promise never resolves
reply.setCookie(`etk-${promptId}`, encryptedTokenKey, {
@ -79,7 +66,7 @@ export default class LoginResolver {
// TODO: Remove
await new Promise((resolve) => setTimeout(resolve, 100));
return {
students,
symbols,
};
}
}

View file

@ -0,0 +1,96 @@
/* eslint-disable class-methods-use-this */
import { Client } from '@wulkanowy/sdk';
import type { DiaryInfo } from '@wulkanowy/sdk/dist/diary/interfaces/diary/diary-info';
import type { SerializedClient } from '@wulkanowy/sdk/dist/diary/interfaces/serialized-client';
import { UserInputError } from 'apollo-server-fastify';
import _ from 'lodash';
import {
Arg, Ctx, Mutation, Resolver,
} from 'type-graphql';
import database from '../../../database/database';
import User from '../../../database/entities/user';
import { decryptSymmetrical, decryptWithPrivateKey, encryptSymmetrical } from '../../../utils';
import { InvalidSymbolError, UnknownPromptError } from '../errors';
import type LoginStudent from '../models/login-student';
import SetSymbolResult from '../models/set-symbol-result';
import type { WebsiteAPIContext } from '../types';
@Resolver()
export default class SetSymbolResolver {
@Mutation(() => SetSymbolResult)
public async setSymbol(
@Arg('promptId') promptId: string,
@Arg('symbol') symbol: string,
@Ctx() { sessionData, request }: WebsiteAPIContext,
): Promise<SetSymbolResult> {
if (symbol !== symbol.trim()) throw new UserInputError('Symbol should be trimmed');
if (symbol !== symbol.toLowerCase()) throw new UserInputError('Symbol should be lowercase');
const prompt = sessionData.prompts.get(promptId);
if (!prompt) throw new UnknownPromptError();
if (!prompt.loginInfo) throw new UserInputError('Login data not provided');
const { loginInfo } = prompt;
const encryptedTokenKey: string | undefined = request.cookies[`etk-${promptId}`];
if (!encryptedTokenKey) throw new UserInputError('Missing etk cookie'); // TODO: Add standard error
const tokenKey = decryptSymmetrical(encryptedTokenKey, prompt.promptSecret);
const privateKey = decryptSymmetrical(loginInfo.encryptedPrivateKey, tokenKey);
const password = decryptWithPrivateKey(loginInfo.encryptedPassword, privateKey);
const serializedClient = JSON.parse(decryptSymmetrical(loginInfo.encryptedClient, tokenKey)) as SerializedClient;
const client = Client.deserialize(serializedClient, () => ({
username: loginInfo.username,
password,
}));
try {
await client.setSymbol(symbol);
} catch (error) {
throw new InvalidSymbolError();
}
const diaryList = await client.getDiaryList();
const diaryStudents = _.groupBy(diaryList.map((e) => e.serialized.info), 'studentId');
const students = _.values(diaryStudents)
.map((diaryInfoList: DiaryInfo[]) => diaryInfoList[0])
.map<LoginStudent>((diaryInfo) => ({
name: `${diaryInfo.studentFirstName} ${diaryInfo.studentSurname}`,
studentId: diaryInfo.studentId,
}));
const reportingUnits = await client.getReportingUnits();
const loginIds = reportingUnits.map((unit) => User.getLoginId(unit.senderId, unit.unitId));
const encryptedClient = encryptSymmetrical(JSON.stringify(client.serialize()), tokenKey);
const encryptedDiaries = encryptSymmetrical(JSON.stringify(diaryList.map(({ serialized }) => serialized)), tokenKey);
const user = await database.userRepo.findOne({
where: {
host: prompt.loginInfo.host,
symbol,
loginIds: {
$in: loginIds,
},
},
});
if (user) {
user.loginIds = loginIds;
user.username = prompt.loginInfo.username;
await user.save();
}
loginInfo.encryptedClient = encryptedClient;
loginInfo.symbolInfo = {
symbol,
encryptedDiaries,
loginIds,
availableStudentIds: students.map(({ studentId }) => studentId),
userId: user?._id,
};
return {
students,
registered: user !== undefined,
};
}
}

View file

@ -9,6 +9,7 @@ import type {
RawServerDefault,
} from 'fastify';
import { registerEnumType } from 'type-graphql';
import type { ObjectID } from 'typeorm';
import type SessionData from './session-data';
export enum StudentsMode {
@ -38,10 +39,16 @@ export interface Prompt {
host: string;
username: string;
encryptedPassword: string;
encryptedSDK: string;
encryptedPrivateKey: string;
publicKey: string;
availableStudentIds: number[];
encryptedClient: string;
symbolInfo?: {
symbol: string;
encryptedDiaries: string;
availableStudentIds: number[];
loginIds: string[];
userId?: ObjectID,
}
};
}
@ -70,6 +77,7 @@ export interface CodeInfo {
expires: Date;
scopes: string[];
clientId: string;
userId: ObjectID,
studentIds: number[];
tokenSecret: string;
publicKey: string;

View file

@ -5,6 +5,7 @@ import _ from 'lodash';
import { ParamError } from '../errors';
import SessionData from '../session-data';
import type { SerializedSDK, Session } from '../types';
import { sha256 } from './crypto';
export * from './crypto';

View file

View file

@ -14,7 +14,11 @@ module.exports = {
rules: {
'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'import/prefer-default-export': ['warn']
'import/prefer-default-export': ['warn'],
"prefer-destructuring": ["error", {
"array": false,
"object": true
}]
},
overrides: [
{

View file

@ -2,6 +2,8 @@ schema: http://localhost:3000/api/website/graphql
documents:
- src/graphql/queries/get-prompt-info.ts
- src/graphql/mutations/login.ts
- src/graphql/mutations/set-symbol.ts
- src/graphql/mutations/create-user.ts
generates:
./src/graphql/generated.ts:
plugins:

View file

@ -10362,6 +10362,14 @@
"integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=",
"dev": true
},
"isemail": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/isemail/-/isemail-3.2.0.tgz",
"integrity": "sha512-zKqkK+O+dGqevc93KNsbZ/TqTUFd46MwWjYOoMrjIMZ51eU7DtQG3Wmd9SQQT7i7RVnuTPEiYEWHU3MSbxC1Tg==",
"requires": {
"punycode": "2.x.x"
}
},
"isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@ -13255,8 +13263,7 @@
"punycode": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz",
"integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==",
"dev": true
"integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A=="
},
"q": {
"version": "1.5.1",

View file

@ -13,6 +13,7 @@
"graphql": "^15.4.0",
"graphql-request": "^3.4.0",
"graphql-tag": "^2.11.0",
"isemail": "^3.2.0",
"vue": "^2.6.11",
"vue-class-component": "^7.2.3",
"vue-property-decorator": "^9.1.2",

View file

@ -0,0 +1,106 @@
<template>
<div>
<v-form v-model="valid" ref="form" @submit.prevent="submit">
<v-card-title>Dodaj adres email</v-card-title>
<v-card-text>
<p>
Dodaj kontaktowy adres email, aby nie utracić dostępu do konta.<br>
<b>Na ten adres będą wysyłane alerty bezpieczeństwa.</b>
</p>
<p>
Twój email <b>nie</b> będzie używany w celach marketingowych
i <b>nie</b> zostanie udostępniony aplikacji <b>{{ promptInfo.application.name }}</b>,
ani żadnym podmiotom trzecim.
</p>
<p>
Podany adres może być inny od adresu używanego w systemie VULCAN.
</p>
</v-card-text>
<v-text-field
label="Adres email"
outlined
type="email"
class="mx-4"
v-model="email"
:rules="emailRules"
/>
<v-alert type="error" class="mx-2" :value="error">
Podczas dodawania adresu email wystąpił błąd
</v-alert>
<v-card-actions>
<v-btn text outlined color="primary" @click="back" :disabled="loading">Wróć</v-btn>
<v-spacer />
<v-btn
color="primary"
type="submit"
:disabled="!valid"
:loading="loading"
>
Zapisz email
</v-btn>
</v-card-actions>
</v-form>
</div>
</template>
<script lang="ts">
import {
Component, Prop, Ref, Vue,
} from 'vue-property-decorator';
import { InputValidationRules } from 'vuetify';
import IsEmail from 'isemail';
import { PromptInfo, VForm } from '@/types';
import { sdk } from '@/pages/authenticate-prompt/sdk';
@Component({
name: 'EmailWindow',
})
export default class EmailWindow extends Vue {
@Ref('form') form!: VForm;
@Prop({
type: Object,
required: true,
})
promptInfo!: PromptInfo
valid = false;
loading = false;
email = '';
error = false;
readonly emailRules: InputValidationRules = [
(v) => v !== '' || 'To pole jest wymagane',
(v) => (IsEmail.validate(v) && v.trim() === v) || 'Email jest niepoprawny',
];
reset(defaultValue?: string) {
this.email = defaultValue ?? '';
this.form.resetValidation();
}
back() {
this.$emit('back');
}
async submit() {
if (!this.valid || this.loading) return;
this.loading = true;
this.error = false;
try {
await sdk.CreateUser({
promptId: this.promptInfo.id,
email: this.email.trim(),
});
this.$emit('create');
} catch (error) {
console.error(error);
this.error = true;
}
this.loading = false;
}
}
</script>

View file

@ -53,17 +53,12 @@
import {
Component, Prop, Ref, Vue,
} from 'vue-property-decorator';
import { PromptInfo } from '@/types';
import { PromptInfo, VForm } from '@/types';
import { hasErrorCode, sdk } from '@/pages/authenticate-prompt/sdk';
import { InputValidationRules } from 'vuetify';
import VueRecaptcha from 'vue-recaptcha';
import { requireEnv } from '@/utils';
interface VForm extends HTMLFormElement {
validate(): boolean;
resetValidation(): void;
}
@Component({
name: 'LoginWindow',
components: {
@ -133,18 +128,21 @@ export default class LoginWindow extends Vue {
if (this.loading || !this.formValid || !this.captchaResponse) return;
this.error = null;
this.loading = true;
const username = this.username.trim();
const host = this.host.trim();
try {
const { login } = await sdk.Login({
promptId: this.promptInfo.id,
host: this.host,
username: this.username,
host,
username,
password: this.password,
captchaResponse: this.captchaResponse,
});
const { students } = login;
this.$emit('login', { students });
const { symbols } = login;
this.$emit('login', { symbols, username });
this.reset();
} catch (error) {
this.recaptcha.reset();
console.error(error);
if (hasErrorCode(error, 'INVALID_VULCAN_CREDENTIALS')) this.error = 'invalid-credentials';
if (hasErrorCode(error, 'CAPTCHA_ERROR')) this.error = 'captcha';

View file

@ -46,7 +46,7 @@
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator';
import { PromptInfo } from '@/types';
import AppInfoDialog from '@/compontents/authenticate-prompt-windows/app-info-dialog.vue';
import AppInfoDialog from '@/compontents/app-info-dialog.vue';
@Component({
name: 'OverviewWindow',

View file

@ -18,37 +18,33 @@
>
Aplikacja nie wymaga dostępu do dzienników uczniów
</v-alert>
<template v-else>
<v-divider />
<v-list>
<v-list-item-group
v-model="studentsValue"
:multiple="mode === StudentsMode.Many"
color="primary"
<v-list v-else subheader>
<v-list-item-group
v-model="studentsValue"
:multiple="mode === StudentsMode.Many"
color="primary"
>
<v-list-item
v-for="student in students"
:key="student.studentId"
:value="student.studentId"
>
<v-list-item
v-for="student in students"
:key="student.studentId"
:value="student.studentId"
>
<template v-slot:default="{ active }">
<v-list-item-action>
<v-icon v-if="active">
{{ mode === StudentsMode.Many ? '$checkboxOn' : '$radioOn' }}
</v-icon>
<v-icon v-else>
{{ mode === StudentsMode.Many ? '$checkboxOff' : '$radioOff' }}
</v-icon>
</v-list-item-action>
<v-list-item-content>
<v-list-item-title>{{ student.name }}</v-list-item-title>
</v-list-item-content>
</template>
</v-list-item>
</v-list-item-group>
</v-list>
<v-divider />
</template>
<template v-slot:default="{ active }">
<v-list-item-action>
<v-icon v-if="active">
{{ mode === StudentsMode.Many ? '$checkboxOn' : '$radioOn' }}
</v-icon>
<v-icon v-else>
{{ mode === StudentsMode.Many ? '$checkboxOff' : '$radioOff' }}
</v-icon>
</v-list-item-action>
<v-list-item-content>
<v-list-item-title>{{ student.name }}</v-list-item-title>
</v-list-item-content>
</template>
</v-list-item>
</v-list-item-group>
</v-list>
<v-card-actions>
<v-btn color="primary" text outlined @click="back">
Wróć

View file

@ -0,0 +1,165 @@
<template>
<div>
<v-form ref="form" @submit.prevent="submit">
<v-card-title>Wybierz symbol</v-card-title>
<v-list subheader>
<v-list-item-group
v-model="selectedSymbol"
color="primary"
:mandatory="selectedSymbol !== null"
>
<v-list-item
v-for="symbol in symbols"
:key="symbol"
:value="symbol"
>
<template v-slot:default="{ active }">
<v-list-item-action>
<v-icon v-if="active">$radioOn</v-icon>
<v-icon v-else>$radioOff</v-icon>
</v-list-item-action>
<v-list-item-content>
<v-list-item-title>
{{ symbol }}
<span class="text--secondary" v-if="symbols.length === 1">(domyślny)</span>
</v-list-item-title>
</v-list-item-content>
</template>
</v-list-item>
<v-list-item :value="customSymbolMode">
<template v-slot:default="{ active }">
<v-list-item-action>
<v-icon v-if="active">$radioOn</v-icon>
<v-icon v-else>$radioOff</v-icon>
</v-list-item-action>
<v-list-item-content>
<v-list-item-title>Własny symbol</v-list-item-title>
</v-list-item-content>
<v-list-item-icon>
<v-icon>mdi-pencil</v-icon>
</v-list-item-icon>
</template>
</v-list-item>
</v-list-item-group>
</v-list>
<v-expand-transition>
<div v-if="selectedSymbol === customSymbolMode">
<v-text-field
outlined
label="Własny symbol"
v-model="customSymbol"
class="mx-4 mt-2"
:readonly="loading"
/>
</div>
</v-expand-transition>
<v-alert type="error" class="mx-2" :value="errorMessage !== null">
{{ errorMessage }}
</v-alert>
<v-card-actions>
<v-btn color="primary" text outlined @click="back" :disabled="loading">
Wróć
</v-btn>
<v-spacer />
<v-btn color="primary" type="submit" :loading="loading" :disabled="!valid">
Wybierz
</v-btn>
</v-card-actions>
</v-form>
</div>
</template>
<script lang="ts">
import {
Component, Prop, Ref, Vue,
} from 'vue-property-decorator';
import { PromptInfo, VForm } from '@/types';
import { hasErrorCode, sdk } from '@/pages/authenticate-prompt/sdk';
// This is a JS Symbol, not a diary symbol
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Symbol)
const customSymbolMode = Symbol('Custom symbol mode');
@Component({
name: 'SymbolsWindow',
})
export default class SymbolsWindow extends Vue {
@Prop({
type: Array,
required: true,
})
symbols!: string[];
@Prop({
required: true,
type: Object,
})
promptInfo!: PromptInfo;
@Ref('form') form!: VForm;
selectedSymbol: string | typeof customSymbolMode | null = null;
readonly customSymbolMode: typeof customSymbolMode = customSymbolMode;
customSymbol = '';
loading = false;
error: 'invalid-symbol' | 'other' | null = null;
get valid() {
if (!this.selectedSymbol) return false;
if (this.selectedSymbol === this.customSymbolMode) {
if (this.customSymbol.trim() === '') return false;
}
return true;
}
reset() {
this.customSymbol = '';
if (this.symbols?.length === 1) this.selectedSymbol = this.symbols[0];
else if (this.symbols?.length === 0) this.selectedSymbol = this.customSymbolMode;
else this.selectedSymbol = null;
if (this.form) this.form.resetValidation();
}
async submit() {
if (!this.valid || this.loading || !this.selectedSymbol) return;
this.loading = true;
this.error = null;
try {
const symbol = (this.selectedSymbol === this.customSymbolMode
? this.customSymbol
: this.selectedSymbol
).trim();
const { setSymbol } = await sdk.SetSymbol({
promptId: this.promptInfo.id,
symbol,
});
this.$emit('set-symbol', {
students: setSymbol.students,
registered: setSymbol.registered,
});
} catch (error) {
if (hasErrorCode(error, 'INVALID_SYMBOL')) this.error = 'invalid-symbol';
else this.error = 'other';
}
this.loading = false;
}
back() {
this.$emit('back');
}
created() {
this.reset();
}
get errorMessage() {
if (this.error === null) return null;
if (this.error === 'invalid-symbol') return 'Błędny symbol';
return 'Podczas wybierania symbolu wystąpił błąd';
}
}
</script>

View file

@ -58,7 +58,14 @@ export type GitHubUser = {
export type Mutation = {
__typename?: 'Mutation';
createUser: CreateUserResult;
login: LoginResult;
setSymbol: SetSymbolResult;
};
export type MutationCreateUserArgs = {
email: Scalars['String'];
promptId: Scalars['String'];
};
export type MutationLoginArgs = {
@ -69,17 +76,46 @@ export type MutationLoginArgs = {
promptId: Scalars['String'];
};
export type LoginResult = {
__typename?: 'LoginResult';
students: Array<LoginResultStudent>;
export type MutationSetSymbolArgs = {
symbol: Scalars['String'];
promptId: Scalars['String'];
};
export type LoginResultStudent = {
__typename?: 'LoginResultStudent';
export type CreateUserResult = {
__typename?: 'CreateUserResult';
success: Scalars['Boolean'];
};
export type LoginResult = {
__typename?: 'LoginResult';
symbols: Array<Scalars['String']>;
};
export type SetSymbolResult = {
__typename?: 'SetSymbolResult';
students: Array<LoginStudent>;
registered: Scalars['Boolean'];
};
export type LoginStudent = {
__typename?: 'LoginStudent';
studentId: Scalars['Int'];
name: Scalars['String'];
};
export type CreateUserMutationVariables = Exact<{
promptId: Scalars['String'];
email: Scalars['String'];
}>;
export type CreateUserMutation = (
{ __typename?: 'Mutation' }
& { createUser: (
{ __typename?: 'CreateUserResult' }
& Pick<CreateUserResult, 'success'>
); }
);
export type LoginMutationVariables = Exact<{
promptId: Scalars['String'];
host: Scalars['String'];
@ -92,9 +128,23 @@ export type LoginMutation = (
{ __typename?: 'Mutation' }
& { login: (
{ __typename?: 'LoginResult' }
& Pick<LoginResult, 'symbols'>
); }
);
export type SetSymbolMutationVariables = Exact<{
promptId: Scalars['String'];
symbol: Scalars['String'];
}>;
export type SetSymbolMutation = (
{ __typename?: 'Mutation' }
& { setSymbol: (
{ __typename?: 'SetSymbolResult' }
& Pick<SetSymbolResult, 'registered'>
& { students: Array<(
{ __typename?: 'LoginResultStudent' }
& Pick<LoginResultStudent, 'studentId' | 'name'>
{ __typename?: 'LoginStudent' }
& Pick<LoginStudent, 'studentId' | 'name'>
)>; }
); }
);
@ -119,6 +169,13 @@ export type GetPromptInfoQuery = (
); }
);
export const CreateUserDocument = gql`
mutation CreateUser($promptId: String!, $email: String!) {
createUser(promptId: $promptId, email: $email) {
success
}
}
`;
export const LoginDocument = gql`
mutation Login($promptId: String!, $host: String!, $username: String!, $password: String!, $captchaResponse: String!) {
login(
@ -128,10 +185,18 @@ export const LoginDocument = gql`
promptId: $promptId
captchaResponse: $captchaResponse
) {
symbols
}
}
`;
export const SetSymbolDocument = gql`
mutation SetSymbol($promptId: String!, $symbol: String!) {
setSymbol(promptId: $promptId, symbol: $symbol) {
students {
studentId
name
}
registered
}
}
`;
@ -162,9 +227,15 @@ export type SdkFunctionWrapper = <T>(action: () => Promise<T>) => Promise<T>;
const defaultWrapper: SdkFunctionWrapper = (sdkFunction) => sdkFunction();
export function getSdk(client: GraphQLClient, withWrapper: SdkFunctionWrapper = defaultWrapper) {
return {
CreateUser(variables: CreateUserMutationVariables, requestHeaders?: Headers): Promise<CreateUserMutation> {
return withWrapper(() => client.request<CreateUserMutation>(print(CreateUserDocument), variables, requestHeaders));
},
Login(variables: LoginMutationVariables, requestHeaders?: Headers): Promise<LoginMutation> {
return withWrapper(() => client.request<LoginMutation>(print(LoginDocument), variables, requestHeaders));
},
SetSymbol(variables: SetSymbolMutationVariables, requestHeaders?: Headers): Promise<SetSymbolMutation> {
return withWrapper(() => client.request<SetSymbolMutation>(print(SetSymbolDocument), variables, requestHeaders));
},
GetPromptInfo(variables: GetPromptInfoQueryVariables, requestHeaders?: Headers): Promise<GetPromptInfoQuery> {
return withWrapper(() => client.request<GetPromptInfoQuery>(print(GetPromptInfoDocument), variables, requestHeaders));
},

View file

@ -0,0 +1,8 @@
import gql from 'graphql-tag';
export default gql`mutation CreateUser($promptId: String!, $email: String!) {
createUser(promptId: $promptId, email: $email) {
success
}
}
`;

View file

@ -2,10 +2,7 @@ import gql from 'graphql-tag';
export default gql`mutation Login($promptId: String!, $host: String!, $username: String!, $password: String!, $captchaResponse: String!) {
login(host: $host, password: $password, username: $username, promptId: $promptId, captchaResponse: $captchaResponse) {
students {
studentId
name
}
symbols
}
}
`;

View file

@ -0,0 +1,12 @@
import gql from 'graphql-tag';
export default gql`mutation SetSymbol($promptId: String!, $symbol: String!) {
setSymbol(promptId: $promptId, symbol: $symbol) {
students {
studentId
name
}
registered
}
}
`;

View file

@ -15,9 +15,6 @@
<v-progress-circular indeterminate :size="96" color="primary" />
</div>
<template v-else>
<div class="pb-1 text--secondary">
Krok <span class="primary--text">{{ step }}/3</span>
</div>
<v-card outlined>
<div class="d-flex justify-center mn-16 avatar__wrapper">
<v-badge
@ -26,11 +23,11 @@
offset-y="16"
bottom
:content="promptInfo.application.verified ? 'Zweryfikowana' : 'Niezweryfikowana'"
:value="step === 1"
:value="step === 'overview'"
>
<transition name="scale">
<v-sheet
v-if="step === 1"
v-if="step === 'overview'"
width="128"
height="128"
class="avatar-sheet mx-4 overflow-hidden"
@ -61,13 +58,13 @@
</v-badge>
</div>
<v-window :value="step">
<v-window-item :value="1">
<v-window-item value="overview">
<overview-window
:promptInfo="promptInfo"
@next="toLoginWindow"
/>
</v-window-item>
<v-window-item :value="2" eager>
<v-window-item value="login" eager>
<login-window
ref="loginWindow"
:prompt-info="promptInfo"
@ -75,12 +72,30 @@
@back="loginBack"
/>
</v-window-item>
<v-window-item :value="3">
<v-window-item value="symbols" eager>
<symbols-window
v-if="symbols !== null"
ref="symbolsWindow"
:prompt-info="promptInfo"
:symbols="symbols"
@back="toLoginWindow"
@set-symbol="setSymbol"
/>
</v-window-item>
<v-window-item value="email" eager>
<email-window
ref="emailWindow"
:prompt-info="promptInfo"
@back="toSymbolsWindow"
@create="createUser"
/>
</v-window-item>
<v-window-item value="students">
<students-window
v-if="students !== null"
:prompt-info="promptInfo"
:students="students"
@back="toLoginWindow"
@back="toSymbolsWindow"
/>
</v-window-item>
</v-window>
@ -122,16 +137,28 @@ import LoginWindow from '@/compontents/authenticate-prompt-windows/login-window.
import StudentsWindow from '@/compontents/authenticate-prompt-windows/students-window.vue';
import { sdk } from '@/pages/authenticate-prompt/sdk';
import DialogApp from '@/compontents/dialog-app.vue';
import EmailWindow from '@/compontents/authenticate-prompt-windows/email-window.vue';
import SymbolsWindow from '@/compontents/authenticate-prompt-windows/symbols-window.vue';
import IsEmail from 'isemail';
@Component({
name: 'AuthenticatePromptApp',
components: {
LoginWindow, OverviewWindow, StudentsWindow, DialogApp,
SymbolsWindow,
EmailWindow,
LoginWindow,
OverviewWindow,
StudentsWindow,
DialogApp,
},
})
export default class AuthenticatePromptApp extends Vue {
@Ref() readonly loginWindow!: LoginWindow
@Ref() readonly emailWindow!: EmailWindow
@Ref() readonly symbolsWindow!: SymbolsWindow
promptInfo: PromptInfo | null = null;
promptId: string | null = null;
@ -140,7 +167,11 @@ export default class AuthenticatePromptApp extends Vue {
students: Student[] | null = null;
step = 1;
username: string | null = null;
symbols: string[] | null = null;
step = 'overview';
async loadPromptInfo() {
this.promptInfoError = false;
@ -167,18 +198,46 @@ export default class AuthenticatePromptApp extends Vue {
}
toLoginWindow() {
this.step = 2;
this.step = 'login';
this.loginWindow.reset();
this.symbols = null;
this.username = null;
}
toSymbolsWindow() {
this.students = null;
this.step = 'symbols';
if (this.symbolsWindow) this.symbolsWindow.reset();
}
loginBack() {
this.step = 1;
this.step = 'overview';
}
login({ students }: { students: Student[] }) {
async login(
{ username, symbols }: {
symbols: string[];
username: string;
},
) {
this.username = username;
this.symbols = symbols;
this.step = 'symbols';
if (this.symbolsWindow) this.symbolsWindow.reset();
}
async setSymbol({ students, registered }: {students: Student[]; registered: boolean}) {
this.students = students;
this.step = 3;
if (registered) this.step = 'students';
else {
if (this.username && IsEmail.validate(this.username)) this.emailWindow.reset(this.username);
else this.emailWindow.reset();
this.step = 'email';
}
}
async createUser() {
this.step = 'students';
}
}
</script>

View file

@ -1,3 +1,8 @@
export interface VForm extends HTMLFormElement {
validate(): boolean;
resetValidation(): void;
}
export enum StudentsMode {
None = 'None',
One = 'One',