Implement modifying app info

This commit is contained in:
Dominik Korsa 2021-02-13 18:23:19 +01:00
parent 52e9fee664
commit 9ff7c35fe9
No known key found for this signature in database
GPG key ID: 9F6F7E66CBF4C1D9
15 changed files with 360 additions and 11 deletions

View file

@ -31,3 +31,11 @@ export class InvalidSymbolError extends ApolloError {
super('Invalid symbol', 'INVALID_SYMBOL'); super('Invalid symbol', 'INVALID_SYMBOL');
} }
} }
export class ApplicationNotFoundError extends ApolloError {
public name = 'ApplicationNotFoundError';
public constructor() {
super('Application not found', 'APPLICATION_NOT_FOUND_ERROR');
}
}

View file

@ -1,9 +1,11 @@
/* eslint-disable class-methods-use-this */ /* eslint-disable class-methods-use-this */
import { URL } from 'url';
import { UserInputError } from 'apollo-server-fastify'; import { UserInputError } from 'apollo-server-fastify';
import { import {
Arg, Ctx, Mutation, Query, Resolver, UnauthorizedError, Arg, Ctx, Mutation, Query, Resolver, UnauthorizedError,
} from 'type-graphql'; } from 'type-graphql';
import ApplicationEntity from '../../../../database/entities/application'; import ApplicationEntity from '../../../../database/entities/application';
import { ApplicationNotFoundError } from '../../errors';
import Application from '../../models/application'; import Application from '../../models/application';
import type { WebsiteAPIContext } from '../../types'; import type { WebsiteAPIContext } from '../../types';
@ -57,4 +59,39 @@ export default class ApplicationResolver {
if (!application.developerId.equals(sessionData.loginState.developerId)) throw new UnauthorizedError(); if (!application.developerId.equals(sessionData.loginState.developerId)) throw new UnauthorizedError();
return Application.fromEntity(application); return Application.fromEntity(application);
} }
@Mutation(() => Application)
public async modifyApplication(
@Arg('id') id: string,
@Arg('name') name: string,
@Arg('homepage', () => String, {
nullable: true,
}) homepage: string | null,
@Ctx() { sessionData }: WebsiteAPIContext,
): Promise<Application> {
if (!sessionData.loginState) throw new UnauthorizedError();
if (name !== name.trim()) throw new UserInputError('Name should be trimmed');
if (name.trim().length < 3) throw new UserInputError('Name is too short');
if (name.trim().length > 32) throw new UserInputError('Name is too long');
if (homepage) {
if (homepage.trim() === '') throw new UserInputError('Homepage should not be an empty string. Use null instead');
let url: URL;
try {
url = new URL(homepage.trim());
} catch {
throw new UserInputError('Homepage URL is invalid');
}
if (url.protocol !== 'http:' && url.protocol !== 'https:') throw new UserInputError('Invalid homepage URL protocol');
}
const application = await ApplicationEntity.findOne(id);
if (!application) throw new ApplicationNotFoundError();
if (!application.developerId.equals(sessionData.loginState.developerId)) throw new UnauthorizedError();
if (application.homepage === homepage && application.name === name) return Application.fromEntity(application);
application.name = name;
application.homepage = homepage;
application.verified = false;
await application.save();
return Application.fromEntity(application);
}
} }

View file

@ -50,7 +50,7 @@ import {
import { InputValidationRules } from 'vuetify'; import { InputValidationRules } from 'vuetify';
import IsEmail from 'isemail'; import IsEmail from 'isemail';
import { PromptInfo, VForm } from '@/types'; import { PromptInfo, VForm } from '@/types';
import { sdk } from '@/pages/authenticate-prompt/sdk'; import { sdk } from '@/graphql/sdk';
@Component({ @Component({
name: 'EmailWindow', name: 'EmailWindow',

View file

@ -54,7 +54,7 @@ import {
Component, Prop, Ref, Vue, Component, Prop, Ref, Vue,
} from 'vue-property-decorator'; } from 'vue-property-decorator';
import { PromptInfo, VForm } from '@/types'; import { PromptInfo, VForm } from '@/types';
import { hasErrorCode, sdk } from '@/pages/authenticate-prompt/sdk'; import { hasErrorCode, sdk } from '@/graphql/sdk';
import { InputValidationRules } from 'vuetify'; import { InputValidationRules } from 'vuetify';
import VueRecaptcha from 'vue-recaptcha'; import VueRecaptcha from 'vue-recaptcha';
import { requireEnv } from '@/utils'; import { requireEnv } from '@/utils';

View file

@ -74,7 +74,7 @@ import {
Component, Prop, Ref, Vue, Component, Prop, Ref, Vue,
} from 'vue-property-decorator'; } from 'vue-property-decorator';
import { PromptInfo, VForm } from '@/types'; import { PromptInfo, VForm } from '@/types';
import { hasErrorCode, sdk } from '@/pages/authenticate-prompt/sdk'; import { hasErrorCode, sdk } from '@/graphql/sdk';
// This is a JS Symbol, not a diary symbol // This is a JS Symbol, not a diary symbol
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Symbol) // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Symbol)

View file

@ -48,7 +48,7 @@
<script lang="ts"> <script lang="ts">
import { Component, Vue, Watch } from 'vue-property-decorator'; import { Component, Vue, Watch } from 'vue-property-decorator';
import { sdk } from '@/pages/authenticate-prompt/sdk'; import { sdk } from '@/graphql/sdk';
@Component({ @Component({
name: 'NewAppDialog', name: 'NewAppDialog',

View file

@ -68,6 +68,8 @@ export type Application = {
name: Scalars['String']; name: Scalars['String'];
iconUrl: Maybe<Scalars['String']>; iconUrl: Maybe<Scalars['String']>;
iconColor: Scalars['String']; iconColor: Scalars['String'];
homepage: Maybe<Scalars['String']>;
verified: Scalars['Boolean'];
}; };
export type LoginState = { export type LoginState = {
@ -83,6 +85,7 @@ export type Mutation = {
login: LoginResult; login: LoginResult;
setSymbol: SetSymbolResult; setSymbol: SetSymbolResult;
createApplication: Application; createApplication: Application;
modifyApplication: Application;
}; };
export type MutationCreateUserArgs = { export type MutationCreateUserArgs = {
@ -107,6 +110,12 @@ export type MutationCreateApplicationArgs = {
name: Scalars['String']; name: Scalars['String'];
}; };
export type MutationModifyApplicationArgs = {
homepage: Maybe<Scalars['String']>;
name: Scalars['String'];
id: Scalars['String'];
};
export type CreateUserResult = { export type CreateUserResult = {
__typename?: 'CreateUserResult'; __typename?: 'CreateUserResult';
success: Scalars['Boolean']; success: Scalars['Boolean'];
@ -170,6 +179,20 @@ export type LoginMutation = (
); } ); }
); );
export type ModifyApplicationMutationVariables = Exact<{
id: Scalars['String'];
name: Scalars['String'];
homepage: Maybe<Scalars['String']>;
}>;
export type ModifyApplicationMutation = (
{ __typename?: 'Mutation' }
& { modifyApplication: (
{ __typename?: 'Application' }
& Pick<Application, 'name' | 'iconUrl' | 'iconColor' | 'homepage' | 'verified'>
); }
);
export type SetSymbolMutationVariables = Exact<{ export type SetSymbolMutationVariables = Exact<{
promptId: Scalars['String']; promptId: Scalars['String'];
symbol: Scalars['String']; symbol: Scalars['String'];
@ -187,6 +210,18 @@ export type SetSymbolMutation = (
); } ); }
); );
export type GetApplicationQueryVariables = Exact<{
id: Scalars['String'];
}>;
export type GetApplicationQuery = (
{ __typename?: 'Query' }
& { application: Maybe<(
{ __typename?: 'Application' }
& Pick<Application, 'name' | 'iconUrl' | 'iconColor' | 'homepage' | 'verified'>
)>; }
);
export type GetApplicationsQueryVariables = Exact<{ [key: string]: never }>; export type GetApplicationsQueryVariables = Exact<{ [key: string]: never }>;
export type GetApplicationsQuery = ( export type GetApplicationsQuery = (
@ -254,6 +289,17 @@ export const LoginDocument = gql`
} }
} }
`; `;
export const ModifyApplicationDocument = gql`
mutation ModifyApplication($id: String!, $name: String!, $homepage: String) {
modifyApplication(id: $id, name: $name, homepage: $homepage) {
name
iconUrl
iconColor
homepage
verified
}
}
`;
export const SetSymbolDocument = gql` export const SetSymbolDocument = gql`
mutation SetSymbol($promptId: String!, $symbol: String!) { mutation SetSymbol($promptId: String!, $symbol: String!) {
setSymbol(promptId: $promptId, symbol: $symbol) { setSymbol(promptId: $promptId, symbol: $symbol) {
@ -265,6 +311,17 @@ export const SetSymbolDocument = gql`
} }
} }
`; `;
export const GetApplicationDocument = gql`
query GetApplication($id: String!) {
application(id: $id) {
name
iconUrl
iconColor
homepage
verified
}
}
`;
export const GetApplicationsDocument = gql` export const GetApplicationsDocument = gql`
query GetApplications { query GetApplications {
applications { applications {
@ -320,9 +377,15 @@ export function getSdk(client: GraphQLClient, withWrapper: SdkFunctionWrapper =
Login(variables: LoginMutationVariables, requestHeaders?: Headers): Promise<LoginMutation> { Login(variables: LoginMutationVariables, requestHeaders?: Headers): Promise<LoginMutation> {
return withWrapper(() => client.request<LoginMutation>(print(LoginDocument), variables, requestHeaders)); return withWrapper(() => client.request<LoginMutation>(print(LoginDocument), variables, requestHeaders));
}, },
ModifyApplication(variables: ModifyApplicationMutationVariables, requestHeaders?: Headers): Promise<ModifyApplicationMutation> {
return withWrapper(() => client.request<ModifyApplicationMutation>(print(ModifyApplicationDocument), variables, requestHeaders));
},
SetSymbol(variables: SetSymbolMutationVariables, requestHeaders?: Headers): Promise<SetSymbolMutation> { SetSymbol(variables: SetSymbolMutationVariables, requestHeaders?: Headers): Promise<SetSymbolMutation> {
return withWrapper(() => client.request<SetSymbolMutation>(print(SetSymbolDocument), variables, requestHeaders)); return withWrapper(() => client.request<SetSymbolMutation>(print(SetSymbolDocument), variables, requestHeaders));
}, },
GetApplication(variables: GetApplicationQueryVariables, requestHeaders?: Headers): Promise<GetApplicationQuery> {
return withWrapper(() => client.request<GetApplicationQuery>(print(GetApplicationDocument), variables, requestHeaders));
},
GetApplications(variables?: GetApplicationsQueryVariables, requestHeaders?: Headers): Promise<GetApplicationsQuery> { GetApplications(variables?: GetApplicationsQueryVariables, requestHeaders?: Headers): Promise<GetApplicationsQuery> {
return withWrapper(() => client.request<GetApplicationsQuery>(print(GetApplicationsDocument), variables, requestHeaders)); return withWrapper(() => client.request<GetApplicationsQuery>(print(GetApplicationsDocument), variables, requestHeaders));
}, },

View file

@ -0,0 +1,12 @@
import gql from 'graphql-tag';
export default gql`mutation ModifyApplication($id: String!, $name: String!, $homepage: String) {
modifyApplication(id: $id, name: $name, homepage: $homepage) {
name
iconUrl
iconColor
homepage
verified
}
}
`;

View file

@ -0,0 +1,11 @@
import gql from 'graphql-tag';
export default gql`query GetApplication($id: String!) {
application(id: $id) {
name
iconUrl
iconColor
homepage
verified
}
}`;

View file

@ -110,7 +110,7 @@ import OverviewWindow from '@/compontents/authenticate-prompt-windows/overview-w
import { PromptInfo, Student } from '@/types'; import { PromptInfo, Student } from '@/types';
import LoginWindow from '@/compontents/authenticate-prompt-windows/login-window.vue'; import LoginWindow from '@/compontents/authenticate-prompt-windows/login-window.vue';
import StudentsWindow from '@/compontents/authenticate-prompt-windows/students-window.vue'; import StudentsWindow from '@/compontents/authenticate-prompt-windows/students-window.vue';
import { sdk } from '@/pages/authenticate-prompt/sdk'; import { sdk } from '@/graphql/sdk';
import DialogApp from '@/compontents/dialog-app.vue'; import DialogApp from '@/compontents/dialog-app.vue';
import EmailWindow from '@/compontents/authenticate-prompt-windows/email-window.vue'; import EmailWindow from '@/compontents/authenticate-prompt-windows/email-window.vue';
import SymbolsWindow from '@/compontents/authenticate-prompt-windows/symbols-window.vue'; import SymbolsWindow from '@/compontents/authenticate-prompt-windows/symbols-window.vue';

View file

@ -45,7 +45,7 @@
</v-menu> </v-menu>
</v-app-bar> </v-app-bar>
<v-main> <v-main>
<router-view /> <router-view :login-state="loginState" />
</v-main> </v-main>
</div> </div>
</v-app> </v-app>
@ -69,7 +69,7 @@
import { Component, Vue } from 'vue-property-decorator'; import { Component, Vue } from 'vue-property-decorator';
import DeveloperSignedOut from '@/pages/developer/views/signed-out.vue'; import DeveloperSignedOut from '@/pages/developer/views/signed-out.vue';
import { LoginState } from '@/graphql/generated'; import { LoginState } from '@/graphql/generated';
import { sdk } from '@/pages/authenticate-prompt/sdk'; import { sdk } from '@/graphql/sdk';
@Component({ @Component({
name: 'DeveloperApp', name: 'DeveloperApp',

View file

@ -9,6 +9,11 @@ const routes: Array<RouteConfig> = [
name: 'Home', name: 'Home',
component: () => import(/* webpackChunkName: "developer-home" */ './views/home.vue'), component: () => import(/* webpackChunkName: "developer-home" */ './views/home.vue'),
}, },
{
path: '/apps/:appId',
name: 'Application',
component: () => import(/* webpackChunkName: "developer-application" */ './views/application.vue'),
},
{ {
path: '*', path: '*',
redirect: '/', redirect: '/',

View file

@ -0,0 +1,213 @@
<template>
<v-container class="application-container" v-if="applicationError">
<v-alert type="error">
Failed to load application info
<template #append>
<v-btn light @click="loadApplication">Retry</v-btn>
</template>
</v-alert>
</v-container>
<v-container class="application-container" v-else-if="application === null">
<v-skeleton-loader type="image" class="mb-4" />
<v-skeleton-loader type="image" class="mb-4" />
</v-container>
<v-container class="application-container" v-else>
<div class="mb-4 d-flex align-center">
<div>
<v-badge
:color="application.verified ? 'green' : 'grey'"
offset-x="48"
offset-y="16"
bottom
:content="application.verified ? 'Verified' : 'Not verified'"
>
<app-icon
large
:color="application.iconColor"
:url="application.iconUrl"
/>
</v-badge>
</div>
<div class="text-h5 ml-4 text-right grow">{{ application.name }}</div>
</div>
<v-card outlined class="mb-4">
<v-form @submit.prevent="modifyApp">
<v-card-title>App information</v-card-title>
<v-card-text class="pb-0">
<v-text-field
label="App name"
outlined
v-model="nameInput"
:error-messages="nameError"
counter="32"
:counter-value="(v) => v.trim().length"
/>
<v-text-field
label="Homepage URL (optional)"
outlined
v-model="homepageInput"
:error-messages="homepageError"
/>
</v-card-text>
<v-card-actions class="px-4">
<v-spacer />
<v-btn
color="primary"
:disabled="!appInfoValid"
type="submit"
:loading="appModifyLoading"
>
Save
</v-btn>
</v-card-actions>
</v-form>
<v-divider />
<v-card-title>Icon</v-card-title>
<v-card-text>
Not implemented yet
</v-card-text>
<v-card-actions class="px-4">
<v-spacer />
<v-btn color="primary" outlined disabled>Remove icon</v-btn>
<v-btn color="primary" disabled>Upload</v-btn>
</v-card-actions>
<v-divider />
<v-card-text>
<v-alert type="warning" text :value="application.verified">
You will lose the verified badge
if you change the app information
or upload a new icon
</v-alert>
Users will see your GitHub profile information,
including your name <b>({{ loginState.name || 'not set' }})</b>,
login <b>({{ loginState.login }})</b>, avatar and profile URL
</v-card-text>
</v-card>
<v-card outlined class="mb-4">
<v-card-title>Client IDs</v-card-title>
</v-card>
<v-card outlined class="mb-4">
<v-card-title>Danger zone</v-card-title>
</v-card>
</v-container>
</template>
<style lang="scss">
.application-container {
max-width: 700px;
}
</style>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator';
import AppIcon from '@/pages/app-icon.vue';
import { GetApplicationQuery, LoginState } from '@/graphql/generated';
import { sdk } from '@/graphql/sdk';
@Component({
name: 'DeveloperApplication',
components: { AppIcon },
})
export default class DeveloperApplication extends Vue {
@Prop({
type: Object,
required: true,
})
loginState!: LoginState
application: GetApplicationQuery['application'] | null = null;
applicationError = false;
nameInput = '';
homepageInput = '';
appModifyLoading = false;
appModifyError = false;
get id(): string {
return this.$route.params.appId;
}
get nameError(): string | null {
if (this.nameInput.trim() === '') return 'The name is required';
if (this.nameInput.trim().length < 3) return 'Name too short';
if (this.nameInput.trim().length > 32) return 'Name too long';
return null;
}
get homepageError(): string | null {
if (this.homepageInput.trim() === '') return null;
let url: URL;
try {
url = new URL(this.homepageInput.trim());
} catch {
return 'Invalid URL';
}
if (url.protocol !== 'http:' && url.protocol !== 'https:') return 'Protocol should be http: or https:';
return null;
}
get appInfoValid(): boolean {
if (!this.application) return false;
if (
this.nameInput.trim() === this.application.name
&& this.homepageInput.trim() === (this.application.homepage ?? '')
) return false;
return this.nameError === null && this.homepageError === null;
}
async loadApplication() {
this.application = null;
this.applicationError = false;
try {
const result = await sdk.GetApplication({
id: this.id,
});
if (!result.application) {
console.error('Application not found');
this.applicationError = true;
} else {
this.application = result.application;
this.updateAppInfoInput();
}
} catch (error) {
console.error(error);
this.applicationError = true;
}
}
updateAppInfoInput() {
if (!this.application) return;
this.nameInput = this.application.name;
this.homepageInput = this.application.homepage ?? '';
}
async created() {
await this.loadApplication();
}
async modifyApp() {
if (this.appModifyLoading || !this.appInfoValid) return;
this.appModifyLoading = true;
this.appModifyError = false;
let homepage: string | null = this.homepageInput.trim();
if (homepage === '') homepage = null;
try {
const result = await sdk.ModifyApplication({
id: this.id,
homepage,
name: this.nameInput.trim(),
});
this.application = result.modifyApplication;
this.updateAppInfoInput();
} catch (error) {
console.error(error);
this.appModifyError = true;
}
this.appModifyLoading = false;
}
}
</script>

View file

@ -53,7 +53,7 @@
<style lang="scss"> <style lang="scss">
.home-container { .home-container {
max-width: 1100px; max-width: 800px;
.applications { .applications {
display: grid; display: grid;
@ -68,15 +68,15 @@
import { Component, Vue } from 'vue-property-decorator'; import { Component, Vue } from 'vue-property-decorator';
import AppIcon from '@/pages/app-icon.vue'; import AppIcon from '@/pages/app-icon.vue';
import NewAppDialog from '@/compontents/developer/new-app-dialog.vue'; import NewAppDialog from '@/compontents/developer/new-app-dialog.vue';
import { Application } from '@/graphql/generated'; import { GetApplicationsQuery } from '@/graphql/generated';
import { sdk } from '@/pages/authenticate-prompt/sdk'; import { sdk } from '@/graphql/sdk';
@Component({ @Component({
name: 'DeveloperHome', name: 'DeveloperHome',
components: { NewAppDialog, AppIcon }, components: { NewAppDialog, AppIcon },
}) })
export default class DeveloperHome extends Vue { export default class DeveloperHome extends Vue {
applications: Application[] | null = null; applications: GetApplicationsQuery['applications'] | null = null;
applicationError = false; applicationError = false;