Implement modifying app info
This commit is contained in:
parent
52e9fee664
commit
9ff7c35fe9
15 changed files with 360 additions and 11 deletions
|
@ -31,3 +31,11 @@ export class InvalidSymbolError extends ApolloError {
|
|||
super('Invalid symbol', 'INVALID_SYMBOL');
|
||||
}
|
||||
}
|
||||
|
||||
export class ApplicationNotFoundError extends ApolloError {
|
||||
public name = 'ApplicationNotFoundError';
|
||||
|
||||
public constructor() {
|
||||
super('Application not found', 'APPLICATION_NOT_FOUND_ERROR');
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,9 +1,11 @@
|
|||
/* eslint-disable class-methods-use-this */
|
||||
import { URL } from 'url';
|
||||
import { UserInputError } from 'apollo-server-fastify';
|
||||
import {
|
||||
Arg, Ctx, Mutation, Query, Resolver, UnauthorizedError,
|
||||
} from 'type-graphql';
|
||||
import ApplicationEntity from '../../../../database/entities/application';
|
||||
import { ApplicationNotFoundError } from '../../errors';
|
||||
import Application from '../../models/application';
|
||||
import type { WebsiteAPIContext } from '../../types';
|
||||
|
||||
|
@ -57,4 +59,39 @@ export default class ApplicationResolver {
|
|||
if (!application.developerId.equals(sessionData.loginState.developerId)) throw new UnauthorizedError();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -50,7 +50,7 @@ import {
|
|||
import { InputValidationRules } from 'vuetify';
|
||||
import IsEmail from 'isemail';
|
||||
import { PromptInfo, VForm } from '@/types';
|
||||
import { sdk } from '@/pages/authenticate-prompt/sdk';
|
||||
import { sdk } from '@/graphql/sdk';
|
||||
|
||||
@Component({
|
||||
name: 'EmailWindow',
|
||||
|
|
|
@ -54,7 +54,7 @@ import {
|
|||
Component, Prop, Ref, Vue,
|
||||
} from 'vue-property-decorator';
|
||||
import { PromptInfo, VForm } from '@/types';
|
||||
import { hasErrorCode, sdk } from '@/pages/authenticate-prompt/sdk';
|
||||
import { hasErrorCode, sdk } from '@/graphql/sdk';
|
||||
import { InputValidationRules } from 'vuetify';
|
||||
import VueRecaptcha from 'vue-recaptcha';
|
||||
import { requireEnv } from '@/utils';
|
||||
|
|
|
@ -74,7 +74,7 @@ import {
|
|||
Component, Prop, Ref, Vue,
|
||||
} from 'vue-property-decorator';
|
||||
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
|
||||
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Symbol)
|
||||
|
|
|
@ -48,7 +48,7 @@
|
|||
|
||||
<script lang="ts">
|
||||
import { Component, Vue, Watch } from 'vue-property-decorator';
|
||||
import { sdk } from '@/pages/authenticate-prompt/sdk';
|
||||
import { sdk } from '@/graphql/sdk';
|
||||
|
||||
@Component({
|
||||
name: 'NewAppDialog',
|
||||
|
|
|
@ -68,6 +68,8 @@ export type Application = {
|
|||
name: Scalars['String'];
|
||||
iconUrl: Maybe<Scalars['String']>;
|
||||
iconColor: Scalars['String'];
|
||||
homepage: Maybe<Scalars['String']>;
|
||||
verified: Scalars['Boolean'];
|
||||
};
|
||||
|
||||
export type LoginState = {
|
||||
|
@ -83,6 +85,7 @@ export type Mutation = {
|
|||
login: LoginResult;
|
||||
setSymbol: SetSymbolResult;
|
||||
createApplication: Application;
|
||||
modifyApplication: Application;
|
||||
};
|
||||
|
||||
export type MutationCreateUserArgs = {
|
||||
|
@ -107,6 +110,12 @@ export type MutationCreateApplicationArgs = {
|
|||
name: Scalars['String'];
|
||||
};
|
||||
|
||||
export type MutationModifyApplicationArgs = {
|
||||
homepage: Maybe<Scalars['String']>;
|
||||
name: Scalars['String'];
|
||||
id: Scalars['String'];
|
||||
};
|
||||
|
||||
export type CreateUserResult = {
|
||||
__typename?: 'CreateUserResult';
|
||||
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<{
|
||||
promptId: 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 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`
|
||||
mutation SetSymbol($promptId: String!, $symbol: String!) {
|
||||
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`
|
||||
query GetApplications {
|
||||
applications {
|
||||
|
@ -320,9 +377,15 @@ export function getSdk(client: GraphQLClient, withWrapper: SdkFunctionWrapper =
|
|||
Login(variables: LoginMutationVariables, requestHeaders?: Headers): Promise<LoginMutation> {
|
||||
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> {
|
||||
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> {
|
||||
return withWrapper(() => client.request<GetApplicationsQuery>(print(GetApplicationsDocument), variables, requestHeaders));
|
||||
},
|
||||
|
|
12
website/src/graphql/mutations/modify-application.ts
Normal file
12
website/src/graphql/mutations/modify-application.ts
Normal 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
|
||||
}
|
||||
}
|
||||
`;
|
11
website/src/graphql/queries/get-application.ts
Normal file
11
website/src/graphql/queries/get-application.ts
Normal 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
|
||||
}
|
||||
}`;
|
|
@ -110,7 +110,7 @@ import OverviewWindow from '@/compontents/authenticate-prompt-windows/overview-w
|
|||
import { PromptInfo, Student } from '@/types';
|
||||
import LoginWindow from '@/compontents/authenticate-prompt-windows/login-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 EmailWindow from '@/compontents/authenticate-prompt-windows/email-window.vue';
|
||||
import SymbolsWindow from '@/compontents/authenticate-prompt-windows/symbols-window.vue';
|
||||
|
|
|
@ -45,7 +45,7 @@
|
|||
</v-menu>
|
||||
</v-app-bar>
|
||||
<v-main>
|
||||
<router-view />
|
||||
<router-view :login-state="loginState" />
|
||||
</v-main>
|
||||
</div>
|
||||
</v-app>
|
||||
|
@ -69,7 +69,7 @@
|
|||
import { Component, Vue } from 'vue-property-decorator';
|
||||
import DeveloperSignedOut from '@/pages/developer/views/signed-out.vue';
|
||||
import { LoginState } from '@/graphql/generated';
|
||||
import { sdk } from '@/pages/authenticate-prompt/sdk';
|
||||
import { sdk } from '@/graphql/sdk';
|
||||
|
||||
@Component({
|
||||
name: 'DeveloperApp',
|
||||
|
|
|
@ -9,6 +9,11 @@ const routes: Array<RouteConfig> = [
|
|||
name: 'Home',
|
||||
component: () => import(/* webpackChunkName: "developer-home" */ './views/home.vue'),
|
||||
},
|
||||
{
|
||||
path: '/apps/:appId',
|
||||
name: 'Application',
|
||||
component: () => import(/* webpackChunkName: "developer-application" */ './views/application.vue'),
|
||||
},
|
||||
{
|
||||
path: '*',
|
||||
redirect: '/',
|
||||
|
|
213
website/src/pages/developer/views/application.vue
Normal file
213
website/src/pages/developer/views/application.vue
Normal 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>
|
|
@ -53,7 +53,7 @@
|
|||
|
||||
<style lang="scss">
|
||||
.home-container {
|
||||
max-width: 1100px;
|
||||
max-width: 800px;
|
||||
|
||||
.applications {
|
||||
display: grid;
|
||||
|
@ -68,15 +68,15 @@
|
|||
import { Component, Vue } from 'vue-property-decorator';
|
||||
import AppIcon from '@/pages/app-icon.vue';
|
||||
import NewAppDialog from '@/compontents/developer/new-app-dialog.vue';
|
||||
import { Application } from '@/graphql/generated';
|
||||
import { sdk } from '@/pages/authenticate-prompt/sdk';
|
||||
import { GetApplicationsQuery } from '@/graphql/generated';
|
||||
import { sdk } from '@/graphql/sdk';
|
||||
|
||||
@Component({
|
||||
name: 'DeveloperHome',
|
||||
components: { NewAppDialog, AppIcon },
|
||||
})
|
||||
export default class DeveloperHome extends Vue {
|
||||
applications: Application[] | null = null;
|
||||
applications: GetApplicationsQuery['applications'] | null = null;
|
||||
|
||||
applicationError = false;
|
||||
|
||||
|
|
Loading…
Reference in a new issue