Add login window (not finished)

This commit is contained in:
Dominik Korsa 2021-01-19 20:20:50 +01:00
parent be486b6da6
commit c1c6812180
No known key found for this signature in database
GPG key ID: 546F986F71A6FE6E
11 changed files with 325 additions and 129 deletions

View file

@ -1,3 +1,5 @@
import { ApolloError } from 'apollo-server-fastify';
export class UnknownPromptError extends Error { export class UnknownPromptError extends Error {
public name = 'UnknownPromptError'; public name = 'UnknownPromptError';
@ -6,10 +8,10 @@ export class UnknownPromptError extends Error {
} }
} }
export class InvalidCredentialsError extends Error { export class InvalidVulcanCredentialsError extends ApolloError {
public name = 'InvalidCredentialsError'; public name = 'InvalidVulcanCredentialsError';
public constructor() { public constructor() {
super('User with provided credentials not found'); super('Invalid vulcan credentials', 'INVALID_VULCAN_CREDENTIALS');
} }
} }

View file

@ -6,9 +6,9 @@ import {
Arg, Ctx, Mutation, Resolver, Arg, Ctx, Mutation, Resolver,
} from 'type-graphql'; } from 'type-graphql';
import { import {
encryptSymmetrical, encryptWithPublicKey, generatePrivatePublicPair, requireEnvHex, encryptSymmetrical, encryptWithPublicKey, generatePrivatePublicPair, isObject, requireEnvHex,
} from '../../../utils'; } from '../../../utils';
import { UnknownPromptError } from '../errors'; import { InvalidVulcanCredentialsError, UnknownPromptError } from '../errors';
import LoginResult from '../models/login-result'; import LoginResult from '../models/login-result';
import type LoginResultStudent from '../models/login-result-student'; import type LoginResultStudent from '../models/login-result-student';
import type { WebsiteAPIContext } from '../types'; import type { WebsiteAPIContext } from '../types';
@ -29,7 +29,12 @@ export default class LoginResolver {
username, username,
password, password,
})); }));
try {
await client.login(); await client.login();
} catch (error) {
if (isObject(error) && error.name === 'InvalidCredentialsError') throw new InvalidVulcanCredentialsError();
throw error;
}
const diaryList = await client.getDiaryList(); const diaryList = await client.getDiaryList();
const { privateKey, publicKey } = await generatePrivatePublicPair(); const { privateKey, publicKey } = await generatePrivatePublicPair();
const encryptedPrivateKey = encryptSymmetrical( const encryptedPrivateKey = encryptSymmetrical(

View file

@ -1,6 +1,7 @@
schema: http://localhost:3000/api/website/graphql schema: http://localhost:3000/api/website/graphql
documents: documents:
- src/graphql/queries/get-prompt-info.ts - src/graphql/queries/get-prompt-info.ts
- src/graphql/mutations/login.ts
generates: generates:
./src/graphql/generated.ts: ./src/graphql/generated.ts:
plugins: plugins:

View file

@ -0,0 +1,101 @@
<template>
<div>
<v-form @submit.prevent="submit">
<v-card-title class="d-block">Zaloguj się do konta VULCAN UONET+</v-card-title>
<div class="mx-4">
<v-select v-model="host" label="Odmiana dziennika" :items="hosts" outlined />
<v-text-field v-model="username" label="Nazwa użytkownika" outlined />
<v-text-field v-model="password" type="password" label="Hasło" outlined />
</div>
<v-alert type="error" class="mx-2" :value="error === 'invalid-credentials'">
Dane logowania nieprawidłowe
</v-alert>
<v-alert type="error" class="mx-2" :value="error === 'other'">
Podczas logowania wystąpił błąd
</v-alert>
<v-card-actions>
<v-btn color="primary" text outlined @click="back" :disabled="loading">
Wróć
</v-btn>
<v-spacer />
<v-btn color="primary" :loading="loading" type="submit">
Zaloguj się
</v-btn>
</v-card-actions>
</v-form>
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator';
import { PromptInfo } from '@/types';
import { hasErrorCode, sdk } from '@/pages/authenticate-prompt/sdk';
@Component({
name: 'LoginWindow',
})
export default class LoginWindow extends Vue {
@Prop({
required: true,
type: Object,
})
promptInfo!: PromptInfo;
readonly hosts = [
{
text: 'Vulcan',
value: 'vulcan.net.pl',
},
{
text: 'Fakelog',
value: 'fakelog.cf',
},
]
host = '';
username = '';
password = '';
loading = false;
error: 'invalid-credentials' | 'other' | null = null;
reset() {
this.host = 'fakelog.cf';
this.username = '';
this.password = '';
}
async submit() {
if (this.loading) return;
this.error = null;
this.loading = true;
try {
const { login } = await sdk.Login({
promptId: this.promptInfo.id,
host: this.host,
username: this.username,
password: this.password,
});
const { students, encryptedPrivateKey } = login;
console.log(students, encryptedPrivateKey);
this.reset();
} catch (error) {
console.error(error);
this.error = hasErrorCode(error, 'INVALID_VULCAN_CREDENTIALS') ? 'invalid-credentials' : 'other';
}
this.loading = false;
}
back() {
if (this.loading) return;
this.$emit('back');
}
created() {
this.reset();
}
}
</script>

View file

@ -0,0 +1,102 @@
<template>
<div>
<div class="pt-16">
<h2 class="text-subtitle-1 text--secondary mt-6 mb-2 px-4">
Aplikacja
<span class="text--primary">{{ promptInfo.application.name }}</span>
chce uzyskać dostęp do twojego konta VULCAN UONET+ przez Wulkanowy Bridge
</h2>
<v-subheader>Uprawnienia aplikacji</v-subheader>
<v-list subheader>
<v-list-item v-for="item in scopeItems" :key="item.key">
<v-list-item-icon>
<v-icon>{{ item.icon }}</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title>
{{ item.title }}
</v-list-item-title>
<v-list-item-subtitle v-if="item.subtitle !== undefined">
{{ item.subtitle }}
</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
</v-list>
<v-alert color="info" text class="mb-2 mx-2">
<span class="font-weight-medium">{{ promptInfo.application.name }}</span>
nie zobaczy twojego hasła
<template #append>
<!-- TODO: Implement -->
<v-btn text color="info">Więcej</v-btn>
</template>
</v-alert>
</div>
<v-card-actions>
<v-btn color="primary" text outlined :href="denyUrl">
Odmów
</v-btn>
<v-spacer />
<v-btn color="primary" @click="next">
Dalej
</v-btn>
</v-card-actions>
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator';
import { PromptInfo } from '@/types';
@Component({
name: 'OverviewWindow',
})
export default class OverviewWindow extends Vue {
@Prop({
required: true,
type: Object,
})
promptInfo!: PromptInfo;
readonly scopeDescriptions: {
key: string;
title: string;
subtitle?: string;
icon: string;
}[] = [
{
key: 'timetable',
title: 'Plan lekcji',
icon: 'mdi-timetable',
},
{
key: 'grades',
title: 'Oceny i punkty',
subtitle: 'Oceny cząstkowe, końcowe, opisowe oraz punkty',
icon: 'mdi-numeric-6-box-multiple-outline',
},
{
key: 'notes',
title: 'Uwagi i pochwały',
icon: 'mdi-note-text-outline',
},
{
key: 'achievements',
title: 'Osiągnięcia',
icon: 'mdi-trophy-outline',
},
]
get scopeItems() {
return this.scopeDescriptions
.filter(({ key }) => this.promptInfo.scopes.includes(key));
}
get denyUrl() {
return `/api/website/deny?prompt_id=${this.promptInfo.id}`;
}
next() {
this.$emit('next');
}
}
</script>

View file

@ -71,6 +71,25 @@ export type LoginResultStudent = {
name: Scalars['String']; name: Scalars['String'];
}; };
export type LoginMutationVariables = Exact<{
promptId: Scalars['String'];
host: Scalars['String'];
username: Scalars['String'];
password: Scalars['String'];
}>;
export type LoginMutation = (
{ __typename?: 'Mutation' }
& { login: (
{ __typename?: 'LoginResult' }
& Pick<LoginResult, 'encryptedPrivateKey'>
& { students: Array<(
{ __typename?: 'LoginResultStudent' }
& Pick<LoginResultStudent, 'studentId' | 'name'>
)>; }
); }
);
export type GetPromptInfoQueryVariables = Exact<{ export type GetPromptInfoQueryVariables = Exact<{
promptId: Scalars['String']; promptId: Scalars['String'];
}>; }>;
@ -79,7 +98,7 @@ export type GetPromptInfoQuery = (
{ __typename?: 'Query' } { __typename?: 'Query' }
& { promptInfo: ( & { promptInfo: (
{ __typename?: 'PromptInfo' } { __typename?: 'PromptInfo' }
& Pick<PromptInfo, 'scopes' | 'studentsMode'> & Pick<PromptInfo, 'id' | 'scopes' | 'studentsMode'>
& { application: ( & { application: (
{ __typename?: 'PromptInfoApplication' } { __typename?: 'PromptInfoApplication' }
& Pick<PromptInfoApplication, 'name' | 'iconUrl' | 'iconColor' | 'verified'> & Pick<PromptInfoApplication, 'name' | 'iconUrl' | 'iconColor' | 'verified'>
@ -87,9 +106,26 @@ export type GetPromptInfoQuery = (
); } ); }
); );
export const LoginDocument = gql`
mutation Login($promptId: String!, $host: String!, $username: String!, $password: String!) {
login(
host: $host
password: $password
username: $username
promptId: $promptId
) {
encryptedPrivateKey
students {
studentId
name
}
}
}
`;
export const GetPromptInfoDocument = gql` export const GetPromptInfoDocument = gql`
query GetPromptInfo($promptId: String!) { query GetPromptInfo($promptId: String!) {
promptInfo(promptId: $promptId) { promptInfo(promptId: $promptId) {
id
scopes scopes
studentsMode studentsMode
application { application {
@ -107,6 +143,9 @@ export type SdkFunctionWrapper = <T>(action: () => Promise<T>) => Promise<T>;
const defaultWrapper: SdkFunctionWrapper = (sdkFunction) => sdkFunction(); const defaultWrapper: SdkFunctionWrapper = (sdkFunction) => sdkFunction();
export function getSdk(client: GraphQLClient, withWrapper: SdkFunctionWrapper = defaultWrapper) { export function getSdk(client: GraphQLClient, withWrapper: SdkFunctionWrapper = defaultWrapper) {
return { return {
Login(variables: LoginMutationVariables, requestHeaders?: Headers): Promise<LoginMutation> {
return withWrapper(() => client.request<LoginMutation>(print(LoginDocument), variables, requestHeaders));
},
GetPromptInfo(variables: GetPromptInfoQueryVariables, requestHeaders?: Headers): Promise<GetPromptInfoQuery> { GetPromptInfo(variables: GetPromptInfoQueryVariables, requestHeaders?: Headers): Promise<GetPromptInfoQuery> {
return withWrapper(() => client.request<GetPromptInfoQuery>(print(GetPromptInfoDocument), variables, requestHeaders)); return withWrapper(() => client.request<GetPromptInfoQuery>(print(GetPromptInfoDocument), variables, requestHeaders));
}, },

View file

@ -0,0 +1,12 @@
import gql from 'graphql-tag';
export default gql`mutation Login($promptId: String!, $host: String!, $username: String!, $password: String!) {
login(host: $host, password: $password, username: $username, promptId: $promptId) {
encryptedPrivateKey
students {
studentId
name
}
}
}
`;

View file

@ -2,6 +2,7 @@ import gql from 'graphql-tag';
export default gql`query GetPromptInfo($promptId: String!) { export default gql`query GetPromptInfo($promptId: String!) {
promptInfo(promptId: $promptId) { promptInfo(promptId: $promptId) {
id
scopes scopes
studentsMode studentsMode
application { application {
@ -11,4 +12,5 @@ export default gql`query GetPromptInfo($promptId: String!) {
verified verified
} }
} }
}`; }
`;

View file

@ -69,62 +69,13 @@
</div> </div>
<v-window :value="step"> <v-window :value="step">
<v-window-item :value="1"> <v-window-item :value="1">
<div> <overview-window
<div class="pt-16"> @next="overviewNext"
<h2 class="text-subtitle-1 text--secondary mt-6 mb-2 px-4"> :promptInfo="promptInfo"
Aplikacja />
<span class="text--primary">{{ promptInfo.application.name }}</span>
chce uzyskać dostęp do twojego konta VULCAN UONET+ przez Wulkanowy Bridge
</h2>
<v-subheader>Uprawnienia aplikacji</v-subheader>
<v-list subheader>
<v-list-item v-for="item in scopeItems" :key="item.key">
<v-list-item-icon>
<v-icon>{{ item.icon }}</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title>
{{ item.title }}
</v-list-item-title>
<v-list-item-subtitle v-if="item.subtitle !== undefined">
{{ item.subtitle }}
</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
</v-list>
<v-alert color="info" text class="mb-2 mx-2">
<span class="font-weight-medium">{{ promptInfo.application.name }}</span>
nie zobaczy twojego hasła
<template #append>
<!-- TODO: Implement -->
<v-btn text color="info">Więcej</v-btn>
</template>
</v-alert>
<v-divider />
</div>
<v-card-actions>
<v-btn color="primary" text outlined :href="denyUrl">
Odmów
</v-btn>
<v-spacer />
<v-btn color="primary" @click="beginLogin">
Dalej
</v-btn>
</v-card-actions>
</div>
</v-window-item> </v-window-item>
<v-window-item :value="2"> <v-window-item :value="2" eager>
<div> <login-window @back="loginBack" ref="loginWindow" :prompt-info="promptInfo" />
<v-card-actions>
<v-btn color="primary" text outlined @click="goBack">
Wróć
</v-btn>
<v-spacer />
<v-btn color="primary">
Zaloguj się
</v-btn>
</v-card-actions>
</div>
</v-window-item> </v-window-item>
</v-window> </v-window>
</v-card> </v-card>
@ -162,31 +113,19 @@
</style> </style>
<script lang="ts"> <script lang="ts">
import { Component, Vue } from 'vue-property-decorator'; import { Component, Ref, Vue } from 'vue-property-decorator';
import { GraphQLClient } from 'graphql-request'; import OverviewWindow from '@/compontents/authenticate-prompt-windows/overview-window.vue';
import { getSdk } from '@/graphql/generated'; import { PromptInfo } from '@/types';
import LoginWindow from '@/compontents/authenticate-prompt-windows/login-window.vue';
export enum StudentsMode { import { sdk } from '@/pages/authenticate-prompt/sdk';
None = 'none',
One = 'one',
Many = 'many',
}
export interface PromptInfo {
scopes: string[];
studentsMode: StudentsMode;
application: {
name: string;
iconUrl: string | null;
iconColor: string;
verified: boolean;
};
}
@Component({ @Component({
name: 'AuthenticatePromptApp', name: 'AuthenticatePromptApp',
components: { LoginWindow, OverviewWindow },
}) })
export default class AuthenticatePromptApp extends Vue { export default class AuthenticatePromptApp extends Vue {
@Ref() readonly loginWindow!: LoginWindow
promptInfo: PromptInfo | null = null; promptInfo: PromptInfo | null = null;
promptId: string | null = null; promptId: string | null = null;
@ -195,55 +134,12 @@ export default class AuthenticatePromptApp extends Vue {
step = 1; step = 1;
readonly scopeDescriptions: {
key: string;
title: string;
subtitle?: string;
icon: string;
}[] = [
{
key: 'timetable',
title: 'Plan lekcji',
icon: 'mdi-timetable',
},
{
key: 'grades',
title: 'Oceny i punkty',
subtitle: 'Oceny cząstkowe, końcowe, opisowe oraz punkty',
icon: 'mdi-numeric-6-box-multiple-outline',
},
{
key: 'notes',
title: 'Uwagi i pochwały',
icon: 'mdi-note-text-outline',
},
{
key: 'achievements',
title: 'Osiągnięcia',
icon: 'mdi-trophy-outline',
},
]
get scopeItems() {
if (this.promptInfo === null) return undefined;
return this.scopeDescriptions
.filter(({ key }) => this.promptInfo?.scopes?.includes(key) ?? false);
}
get denyUrl() {
if (!this.promptId) return undefined;
return `/api/website/deny?prompt_id=${this.promptId}`;
}
async loadPromptInfo() { async loadPromptInfo() {
this.promptInfoError = false; this.promptInfoError = false;
this.promptInfo = null; this.promptInfo = null;
if (!this.promptId) return; if (!this.promptId) return;
const client = new GraphQLClient('/api/website/graphql');
const sdk = getSdk(client);
try { try {
const { promptInfo } = await sdk.GetPromptInfo({ const { promptInfo } = await sdk.GetPromptInfo({
promptId: this.promptId, promptId: this.promptId,
@ -262,12 +158,13 @@ export default class AuthenticatePromptApp extends Vue {
await this.loadPromptInfo(); await this.loadPromptInfo();
} }
goBack() { loginBack() {
this.step -= 1; this.step = 1;
} }
beginLogin() { overviewNext() {
this.step = 2; this.step = 2;
this.loginWindow.reset();
} }
} }
</script> </script>

View file

@ -0,0 +1,18 @@
import { ClientError, GraphQLClient } from 'graphql-request';
import { getSdk } from '@/graphql/generated';
import { GraphQLError } from 'graphql-request/dist/types';
const client = new GraphQLClient('/api/website/graphql');
export const sdk = getSdk(client);
export interface GraphQLErrorFull extends GraphQLError {
extensions?: {
code?: string;
};
}
export function hasErrorCode(error: unknown, code: string): boolean {
if (!(error instanceof ClientError)) return false;
return error.response.errors
?.some((resError: GraphQLErrorFull) => resError.extensions?.code === code) ?? false;
}

17
website/src/types.ts Normal file
View file

@ -0,0 +1,17 @@
export enum StudentsMode {
None = 'none',
One = 'one',
Many = 'many',
}
export interface PromptInfo {
id: string;
scopes: string[];
studentsMode: StudentsMode;
application: {
name: string;
iconUrl: string | null;
iconColor: string;
verified: boolean;
};
}