Compare commits
23 commits
Author | SHA1 | Date | |
---|---|---|---|
|
709934b021 | ||
|
9ff7c35fe9 | ||
|
52e9fee664 | ||
|
730f7d5b34 | ||
|
d5f69af319 | ||
|
a2756fadc8 | ||
|
f1e42085be | ||
|
36a93b10d1 | ||
|
b75a31a201 | ||
|
8f47ee3aec | ||
|
d70905e8c5 | ||
|
b0c0a7202d | ||
|
0e3957fd45 | ||
|
cac25472c6 | ||
|
a1064b2a10 | ||
|
084f38bcbf | ||
|
c045fde577 | ||
|
b47d500331 | ||
|
576f82f029 | ||
|
8083bcef8e | ||
|
0dd43c08b9 | ||
|
866ffc55c7 | ||
|
aff93deef8 |
58 changed files with 2208 additions and 473 deletions
1030
backend/package-lock.json
generated
1030
backend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -8,7 +8,7 @@
|
|||
"lint": "eslint src -c .eslintrc.json --ext .ts",
|
||||
"lint:fix": "eslint src -c .eslintrc.json --ext .ts --fix",
|
||||
"start": "ts-node .",
|
||||
"dev": "ts-node-dev ."
|
||||
"dev": "nodemon --watch \"src/**\" --ext \"ts,json\" --exec \"npm run start\""
|
||||
},
|
||||
"author": "Dominik Korsa <dominik.korsa@gmail.com>",
|
||||
"license": "MIT",
|
||||
|
@ -49,6 +49,6 @@
|
|||
"eslint-config-airbnb": "^18.2.1",
|
||||
"eslint-config-airbnb-typescript": "^12.0.0",
|
||||
"eslint-plugin-import": "^2.22.1",
|
||||
"ts-node-dev": "^1.1.1"
|
||||
"nodemon": "^2.0.7"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,35 +1,30 @@
|
|||
import type { Connection, Repository } from 'typeorm';
|
||||
import type { Connection } from 'typeorm';
|
||||
import { createConnection } from 'typeorm';
|
||||
import Application from './entities/application';
|
||||
import Token from './entities/token';
|
||||
import User from './entities/user';
|
||||
import { requireEnv } from '../utils';
|
||||
import ApplicationEntity from './entities/application';
|
||||
import ClientEntity from './entities/client';
|
||||
import DeveloperEntity from './entities/developer';
|
||||
import TokenEntity from './entities/token';
|
||||
import UserEntity from './entities/user';
|
||||
|
||||
class Database {
|
||||
private connection!: Connection;
|
||||
|
||||
public applicationRepo!: Repository<Application>;
|
||||
|
||||
public userRepo!: Repository<User>;
|
||||
|
||||
public tokenRepo!: Repository<Token>;
|
||||
|
||||
public async connect(): Promise<void> {
|
||||
this.connection = await createConnection({
|
||||
type: 'mongodb',
|
||||
host: 'localhost',
|
||||
port: 27017,
|
||||
database: 'wulkanowy-bridge-local',
|
||||
url: requireEnv('DATABASE_URL'),
|
||||
useNewUrlParser: true,
|
||||
entities: [
|
||||
Application,
|
||||
User,
|
||||
Token,
|
||||
ApplicationEntity,
|
||||
UserEntity,
|
||||
TokenEntity,
|
||||
DeveloperEntity,
|
||||
ClientEntity,
|
||||
],
|
||||
useUnifiedTopology: true,
|
||||
logging: false,
|
||||
});
|
||||
this.applicationRepo = this.connection.getRepository(Application);
|
||||
this.userRepo = this.connection.getRepository(User);
|
||||
this.tokenRepo = this.connection.getRepository(Token);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,21 +1,16 @@
|
|||
import { nanoid } from 'nanoid';
|
||||
import type { ObjectID } from 'typeorm';
|
||||
import {
|
||||
BaseEntity,
|
||||
Column, Entity, ObjectIdColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity()
|
||||
export default class Application extends BaseEntity {
|
||||
@Entity({
|
||||
name: 'applications',
|
||||
})
|
||||
export default class ApplicationEntity extends BaseEntity {
|
||||
@ObjectIdColumn()
|
||||
public _id!: ObjectID;
|
||||
|
||||
@Column()
|
||||
public clientId!: string;
|
||||
|
||||
@Column()
|
||||
public clientSecret!: string;
|
||||
|
||||
@Column()
|
||||
public name!: string;
|
||||
|
||||
|
@ -29,15 +24,8 @@ export default class Application extends BaseEntity {
|
|||
public verified!: boolean;
|
||||
|
||||
@Column()
|
||||
public redirectUris!: string[];
|
||||
|
||||
@Column()
|
||||
public ownerGitHubLogin!: string;
|
||||
public developerId!: ObjectID;
|
||||
|
||||
@Column()
|
||||
public homepage!: string | null;
|
||||
|
||||
public static generateClientId(): string {
|
||||
return nanoid(12);
|
||||
}
|
||||
}
|
||||
|
|
36
backend/src/database/entities/client.ts
Normal file
36
backend/src/database/entities/client.ts
Normal file
|
@ -0,0 +1,36 @@
|
|||
import { nanoid } from 'nanoid';
|
||||
import type { ObjectID } from 'typeorm';
|
||||
import {
|
||||
BaseEntity, Column, Entity, ObjectIdColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity({
|
||||
name: 'clients',
|
||||
})
|
||||
export default class ClientEntity extends BaseEntity {
|
||||
@ObjectIdColumn()
|
||||
public _id!: ObjectID;
|
||||
|
||||
@Column()
|
||||
public clientId!: string;
|
||||
|
||||
@Column()
|
||||
public clientSecret!: string;
|
||||
|
||||
@Column()
|
||||
public name!: string;
|
||||
|
||||
@Column()
|
||||
public redirectUris!: string[];
|
||||
|
||||
@Column()
|
||||
public applicationId!: ObjectID;
|
||||
|
||||
public static generateClientId(): string {
|
||||
return nanoid(12);
|
||||
}
|
||||
|
||||
public static generateClientSecret(): string {
|
||||
return nanoid(32);
|
||||
}
|
||||
}
|
18
backend/src/database/entities/developer.ts
Normal file
18
backend/src/database/entities/developer.ts
Normal file
|
@ -0,0 +1,18 @@
|
|||
import type { ObjectID } from 'typeorm';
|
||||
import {
|
||||
BaseEntity, Column, Entity, ObjectIdColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity({
|
||||
name: 'developers',
|
||||
})
|
||||
export default class DeveloperEntity extends BaseEntity {
|
||||
@ObjectIdColumn()
|
||||
public _id!: ObjectID;
|
||||
|
||||
@Column()
|
||||
public gitHubLogin!: string;
|
||||
|
||||
@Column()
|
||||
public gitHubId!: string;
|
||||
}
|
|
@ -4,8 +4,10 @@ import {
|
|||
BaseEntity, Column, Entity, ObjectIdColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity()
|
||||
export default class Token extends BaseEntity {
|
||||
@Entity({
|
||||
name: 'tokens',
|
||||
})
|
||||
export default class TokenEntity extends BaseEntity {
|
||||
@ObjectIdColumn()
|
||||
public _id!: ObjectID;
|
||||
|
||||
|
|
|
@ -3,8 +3,10 @@ import {
|
|||
BaseEntity, Column, Entity, ObjectIdColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity()
|
||||
export default class User extends BaseEntity {
|
||||
@Entity({
|
||||
name: 'users',
|
||||
})
|
||||
export default class UserEntity extends BaseEntity {
|
||||
@ObjectIdColumn()
|
||||
public _id!: ObjectID;
|
||||
|
||||
|
|
|
@ -5,6 +5,7 @@ export const getUserQuery = gql`query GetUser($login: String!) {
|
|||
login
|
||||
name
|
||||
url
|
||||
avatarUrl
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
@ -13,6 +14,7 @@ export interface User {
|
|||
login: string;
|
||||
name: string | null;
|
||||
url: string;
|
||||
avatarUrl: string;
|
||||
}
|
||||
|
||||
export interface GetUserQueryResult {
|
||||
|
|
18
backend/src/graphql/github/queries/get-viewer.ts
Normal file
18
backend/src/graphql/github/queries/get-viewer.ts
Normal file
|
@ -0,0 +1,18 @@
|
|||
import gql from 'graphql-tag';
|
||||
|
||||
export const getViewerQuery = gql`query GetViewer {
|
||||
viewer {
|
||||
login
|
||||
id
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export interface Viewer {
|
||||
login: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface GetViewerQueryResult {
|
||||
viewer: Viewer
|
||||
}
|
|
@ -2,13 +2,23 @@ import { GraphQLClient } from 'graphql-request';
|
|||
import { requireEnv } from '../../utils';
|
||||
import type { GetUserQueryResult, User } from './queries/get-user';
|
||||
import { getUserQuery } from './queries/get-user';
|
||||
import type { GetViewerQueryResult, Viewer } from './queries/get-viewer';
|
||||
import { getViewerQuery } from './queries/get-viewer';
|
||||
|
||||
const client = new GraphQLClient('https://api.github.com/graphql');
|
||||
client.setHeader('Authorization', `bearer ${requireEnv('GITHUB_API_TOKEN')}`);
|
||||
|
||||
export async function getUser(login: string): Promise<User> {
|
||||
const { user } = await client.request<GetUserQueryResult>(getUserQuery, {
|
||||
login,
|
||||
}, {
|
||||
Authorization: `bearer ${requireEnv('GITHUB_API_TOKEN')}`,
|
||||
});
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function getViewer(accessToken: string, tokenType: string): Promise<Viewer> {
|
||||
const { viewer } = await client.request<GetViewerQueryResult>(getViewerQuery, {}, {
|
||||
Authorization: `${tokenType} ${accessToken}`,
|
||||
});
|
||||
return viewer;
|
||||
}
|
||||
|
|
|
@ -2,7 +2,7 @@ import _ from 'lodash';
|
|||
import { nanoid } from 'nanoid';
|
||||
import urlJoin from 'url-join';
|
||||
import { scopes, websitePrefix } from '../../constants';
|
||||
import database from '../../database/database';
|
||||
import ClientEntity from '../../database/entities/client';
|
||||
import { ParamError, ScopeError } from '../../errors';
|
||||
import type { MyFastifyInstance, StudentsMode } from '../../types';
|
||||
|
||||
|
@ -42,16 +42,16 @@ export default function registerAuthorize(server: MyFastifyInstance): void {
|
|||
return;
|
||||
}
|
||||
|
||||
const application = await database.applicationRepo.findOne({
|
||||
const client = await ClientEntity.findOne({
|
||||
where: {
|
||||
clientId: request.query.client_id,
|
||||
},
|
||||
});
|
||||
if (application === undefined) {
|
||||
if (client === undefined) {
|
||||
await reply.redirect(urlJoin(websitePrefix, '/prompt-error?code=unknown_application'));
|
||||
return;
|
||||
}
|
||||
if (!application.redirectUris.includes(request.query.redirect_uri)) {
|
||||
if (!client.redirectUris.includes(request.query.redirect_uri)) {
|
||||
await reply.redirect(urlJoin(websitePrefix, '/prompt-error?code=unknown_redirect_uri'));
|
||||
return;
|
||||
}
|
||||
|
@ -88,7 +88,7 @@ export default function registerAuthorize(server: MyFastifyInstance): void {
|
|||
|
||||
const sessionData = getSessionData(request.session);
|
||||
sessionData.authPrompts.set(promptId, {
|
||||
clientId: request.query.client_id,
|
||||
clientId: client.clientId,
|
||||
redirectUri: request.query.redirect_uri,
|
||||
scopes: requestedScopes,
|
||||
state: request.query.state,
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import type { FastifyReply } from 'fastify';
|
||||
import { getCode, invalidateCode } from '../../codes';
|
||||
import database from '../../database/database';
|
||||
import Token from '../../database/entities/token';
|
||||
import ClientEntity from '../../database/entities/client';
|
||||
import TokenEntity from '../../database/entities/token';
|
||||
import { ParamError } from '../../errors';
|
||||
import type { CodeInfo, MyFastifyInstance, TokenContent } from '../../types';
|
||||
|
||||
|
@ -67,13 +67,13 @@ export default function registerToken(server: MyFastifyInstance): void {
|
|||
return;
|
||||
}
|
||||
|
||||
const application = await database.applicationRepo.findOne({
|
||||
const client = await ClientEntity.findOne({
|
||||
where: {
|
||||
clientId: request.body.client_id,
|
||||
},
|
||||
});
|
||||
if (!application) {
|
||||
await sendCustomError(reply, 'invalid_client', 'Application not found', 401);
|
||||
if (!client) {
|
||||
await sendCustomError(reply, 'invalid_client', 'Client id not found', 401);
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -96,15 +96,15 @@ export default function registerToken(server: MyFastifyInstance): void {
|
|||
}
|
||||
} else {
|
||||
validateParam('client_secret', request.body.client_secret);
|
||||
if (application.clientSecret !== request.body.client_secret) {
|
||||
if (client.clientSecret !== request.body.client_secret) {
|
||||
await sendCustomError(reply, 'invalid_client', 'Invalid client secret', 401);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const tokenId = Token.generateTokenId();
|
||||
const tokenId = TokenEntity.generateTokenId();
|
||||
|
||||
const token = new Token();
|
||||
const token = new TokenEntity();
|
||||
token.tokenId = tokenId;
|
||||
token.creationDate = new Date();
|
||||
token.clientId = codeInfo.clientId;
|
||||
|
@ -117,7 +117,7 @@ export default function registerToken(server: MyFastifyInstance): void {
|
|||
token.encryptedSDK = codeInfo.encryptedSDK;
|
||||
token.publicKey = codeInfo.publicKey;
|
||||
|
||||
await database.tokenRepo.save(token);
|
||||
await token.save();
|
||||
|
||||
const content: TokenContent = {
|
||||
tk: tokenKey,
|
||||
|
|
91
backend/src/routes/website-api/developer/github-callback.ts
Normal file
91
backend/src/routes/website-api/developer/github-callback.ts
Normal file
|
@ -0,0 +1,91 @@
|
|||
import got from 'got';
|
||||
import DeveloperEntity from '../../../database/entities/developer';
|
||||
import { ParamError } from '../../../errors';
|
||||
import { getViewer } from '../../../graphql/github/sdk';
|
||||
import type SessionData from '../../../session-data';
|
||||
import type { GitHubAuthorization, MyFastifyInstance } from '../../../types';
|
||||
import {
|
||||
getSessionData, isObject, requireEnv, validateOptionalParam, validateParam,
|
||||
} from '../../../utils';
|
||||
|
||||
function getAuthorization(sessionData: SessionData, state?: string): GitHubAuthorization | null {
|
||||
if (!state) return null;
|
||||
return sessionData.gitHubAuthorizations.get(state) ?? null;
|
||||
}
|
||||
|
||||
export default function registerGitHubCallback(server: MyFastifyInstance): void {
|
||||
server.get('/developer/github-callback', async (
|
||||
request,
|
||||
reply,
|
||||
) => {
|
||||
const sessionData = getSessionData(request.session);
|
||||
if (!isObject(request.query)) {
|
||||
throw server.httpErrors.badRequest('Request query is not an object');
|
||||
}
|
||||
try {
|
||||
validateOptionalParam('error', request.query.error);
|
||||
validateOptionalParam('state', request.query.state);
|
||||
} catch (error) {
|
||||
server.log.error(error);
|
||||
if (error instanceof ParamError) {
|
||||
throw server.httpErrors.badRequest(error.message);
|
||||
}
|
||||
throw server.httpErrors.internalServerError();
|
||||
}
|
||||
const authorization = getAuthorization(sessionData, request.query.state);
|
||||
if (request.query.error) {
|
||||
if (authorization && request.query.state) sessionData.gitHubAuthorizations.delete(request.query.state);
|
||||
if (request.query.error === 'access_denied') {
|
||||
await reply.redirect(authorization?.returnTo ?? '/');
|
||||
}
|
||||
throw server.httpErrors.internalServerError(`Got error response: "${request.query.error}"`);
|
||||
}
|
||||
if (!request.query.state) throw server.httpErrors.badRequest('Missing state param');
|
||||
if (!authorization) throw server.httpErrors.badRequest('Authorization not found');
|
||||
try {
|
||||
validateParam('code', request.query.code);
|
||||
} catch (error) {
|
||||
server.log.error(error);
|
||||
if (error instanceof ParamError) {
|
||||
throw server.httpErrors.badRequest(error.message);
|
||||
}
|
||||
throw server.httpErrors.internalServerError();
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await got.post<{
|
||||
token_type: string;
|
||||
access_token: string;
|
||||
scope: string;
|
||||
}>('https://github.com/login/oauth/access_token', {
|
||||
searchParams: {
|
||||
client_id: requireEnv('GITHUB_CLIENT_ID'),
|
||||
client_secret: requireEnv('GITHUB_CLIENT_SECRET'),
|
||||
code: request.query.code,
|
||||
},
|
||||
responseType: 'json',
|
||||
});
|
||||
const viewer = await getViewer(response.body.access_token, response.body.token_type);
|
||||
console.log(viewer);
|
||||
let developer = await DeveloperEntity.findOne({
|
||||
where: {
|
||||
gitHubId: viewer.id,
|
||||
},
|
||||
});
|
||||
if (!developer) {
|
||||
developer = new DeveloperEntity();
|
||||
developer.gitHubId = viewer.id;
|
||||
}
|
||||
developer.gitHubLogin = viewer.login;
|
||||
await developer.save();
|
||||
sessionData.loginState = {
|
||||
developerId: developer._id,
|
||||
};
|
||||
sessionData.gitHubAuthorizations.delete(request.query.state);
|
||||
await reply.redirect(authorization.returnTo);
|
||||
} catch (error) {
|
||||
server.log.error(error);
|
||||
throw server.httpErrors.internalServerError();
|
||||
}
|
||||
});
|
||||
}
|
41
backend/src/routes/website-api/developer/github-sign-in.ts
Normal file
41
backend/src/routes/website-api/developer/github-sign-in.ts
Normal file
|
@ -0,0 +1,41 @@
|
|||
import { URL } from 'url';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { ParamError } from '../../../errors';
|
||||
import type { MyFastifyInstance } from '../../../types';
|
||||
import {
|
||||
getSessionData, isObject, requireEnv, validateParam,
|
||||
} from '../../../utils';
|
||||
|
||||
export default function registerGitHubSignIn(server: MyFastifyInstance): void {
|
||||
server.get('/developer/sign-in/github', async (
|
||||
request,
|
||||
reply,
|
||||
) => {
|
||||
if (!isObject(request.query)) {
|
||||
throw server.httpErrors.badRequest('Request query is not an object');
|
||||
}
|
||||
try {
|
||||
validateParam('return_to', request.query.return_to);
|
||||
} catch (error) {
|
||||
server.log.error(error);
|
||||
if (error instanceof ParamError) {
|
||||
throw server.httpErrors.badRequest(error.message);
|
||||
}
|
||||
throw server.httpErrors.internalServerError();
|
||||
}
|
||||
const sessionData = getSessionData(request.session);
|
||||
let state: string;
|
||||
do {
|
||||
state = nanoid();
|
||||
} while (sessionData.gitHubAuthorizations.has(state));
|
||||
const authorizeUrl = new URL('https://github.com/login/oauth/authorize');
|
||||
authorizeUrl.searchParams.set('client_id', requireEnv('GITHUB_CLIENT_ID'));
|
||||
authorizeUrl.searchParams.set('response_type', 'code');
|
||||
authorizeUrl.searchParams.set('redirect_uri', requireEnv('GITHUB_REDIRECT_URL'));
|
||||
authorizeUrl.searchParams.set('state', state);
|
||||
sessionData.gitHubAuthorizations.set(state, {
|
||||
returnTo: request.query.return_to,
|
||||
});
|
||||
await reply.redirect(authorizeUrl.toString());
|
||||
});
|
||||
}
|
13
backend/src/routes/website-api/developer/sign-out.ts
Normal file
13
backend/src/routes/website-api/developer/sign-out.ts
Normal file
|
@ -0,0 +1,13 @@
|
|||
import type { MyFastifyInstance } from '../../../types';
|
||||
import { getSessionData } from '../../../utils';
|
||||
|
||||
export default function registerSignOut(server: MyFastifyInstance): void {
|
||||
server.get('/developer/sign-out', async (
|
||||
request,
|
||||
reply,
|
||||
) => {
|
||||
const sessionData = getSessionData(request.session);
|
||||
sessionData.loginState = null;
|
||||
await reply.redirect('/developer');
|
||||
});
|
||||
}
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,10 +4,15 @@ import type { ApolloContext, MyFastifyInstance } from '../../types';
|
|||
import { getSessionData } from '../../utils';
|
||||
import registerAllow from './allow';
|
||||
import registerDeny from './deny';
|
||||
import registerGitHubCallback from './developer/github-callback';
|
||||
import registerGitHubSignIn from './developer/github-sign-in';
|
||||
import registerSignOut from './developer/sign-out';
|
||||
import CreateUserResolver from './resolvers/authenticate-prompt/create-user-resolver';
|
||||
import LoginResolver from './resolvers/authenticate-prompt/login-resolver';
|
||||
import PromptInfoResolver from './resolvers/authenticate-prompt/prompt-info-resolver';
|
||||
import SetSymbolResolver from './resolvers/authenticate-prompt/set-symbol-resolver';
|
||||
import ApplicationResolver from './resolvers/developer/application';
|
||||
import LoginStateResolver from './resolvers/developer/get-login-state';
|
||||
import type { WebsiteAPIContext } from './types';
|
||||
|
||||
export default async function registerWebsiteApi(server: MyFastifyInstance): Promise<void> {
|
||||
|
@ -18,6 +23,8 @@ export default async function registerWebsiteApi(server: MyFastifyInstance): Pro
|
|||
LoginResolver,
|
||||
SetSymbolResolver,
|
||||
CreateUserResolver,
|
||||
LoginStateResolver,
|
||||
ApplicationResolver,
|
||||
],
|
||||
});
|
||||
const apolloServer = new ApolloServer({
|
||||
|
@ -35,4 +42,8 @@ export default async function registerWebsiteApi(server: MyFastifyInstance): Pro
|
|||
|
||||
registerDeny(server);
|
||||
registerAllow(server);
|
||||
|
||||
registerGitHubSignIn(server);
|
||||
registerGitHubCallback(server);
|
||||
registerSignOut(server);
|
||||
}
|
||||
|
|
38
backend/src/routes/website-api/models/application.ts
Normal file
38
backend/src/routes/website-api/models/application.ts
Normal file
|
@ -0,0 +1,38 @@
|
|||
import { Field, ObjectType } from 'type-graphql';
|
||||
import type ApplicationEntity from '../../../database/entities/application';
|
||||
|
||||
@ObjectType()
|
||||
export default class Application {
|
||||
@Field(() => String)
|
||||
public id!: string;
|
||||
|
||||
@Field(() => String)
|
||||
public name!: string;
|
||||
|
||||
@Field(() => String, {
|
||||
nullable: true,
|
||||
})
|
||||
public iconUrl!: string | null;
|
||||
|
||||
@Field(() => String)
|
||||
public iconColor!: string;
|
||||
|
||||
@Field(() => String, {
|
||||
nullable: true,
|
||||
})
|
||||
public homepage!: string | null;
|
||||
|
||||
@Field(() => Boolean)
|
||||
public verified!: boolean;
|
||||
|
||||
public static fromEntity(entity: ApplicationEntity): Application {
|
||||
return {
|
||||
id: entity._id.toHexString(),
|
||||
iconColor: entity.iconColor,
|
||||
iconUrl: entity.iconUrl,
|
||||
name: entity.name,
|
||||
homepage: entity.homepage,
|
||||
verified: entity.verified,
|
||||
};
|
||||
}
|
||||
}
|
15
backend/src/routes/website-api/models/login-state.ts
Normal file
15
backend/src/routes/website-api/models/login-state.ts
Normal file
|
@ -0,0 +1,15 @@
|
|||
import { Field, ObjectType } from 'type-graphql';
|
||||
|
||||
@ObjectType()
|
||||
export default class LoginState {
|
||||
@Field(() => String, {
|
||||
nullable: true,
|
||||
})
|
||||
public name!: string | null;
|
||||
|
||||
@Field(() => String)
|
||||
public login!: string;
|
||||
|
||||
@Field(() => String)
|
||||
public avatarUrl!: string;
|
||||
}
|
|
@ -18,7 +18,7 @@ export default class PromptInfoApplication {
|
|||
public verified!: boolean;
|
||||
|
||||
@Field(() => GitHubUser)
|
||||
public owner!: GitHubUser;
|
||||
public developer!: GitHubUser;
|
||||
|
||||
@Field(() => String, {
|
||||
nullable: true,
|
||||
|
|
|
@ -10,9 +10,6 @@ export default class PromptInfo {
|
|||
@Field(() => [String])
|
||||
public scopes!: string[];
|
||||
|
||||
@Field(() => String)
|
||||
public clientId!: string;
|
||||
|
||||
@Field(() => StudentsMode)
|
||||
public studentsMode!: StudentsMode;
|
||||
|
||||
|
|
|
@ -3,8 +3,7 @@ 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 UserEntity from '../../../../database/entities/user';
|
||||
import { UnknownPromptError } from '../../errors';
|
||||
import CreateUserResult from '../../models/create-user-result';
|
||||
import type { WebsiteAPIContext } from '../../types';
|
||||
|
@ -23,7 +22,7 @@ export default class CreateUserResolver {
|
|||
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({
|
||||
const existingUser = await UserEntity.findOne({
|
||||
where: {
|
||||
host: prompt.loginInfo.host,
|
||||
symbol: prompt.loginInfo.symbolInfo.symbol,
|
||||
|
@ -33,13 +32,13 @@ export default class CreateUserResolver {
|
|||
},
|
||||
});
|
||||
if (existingUser !== undefined) throw new UserInputError('User already exists');
|
||||
const user = new User();
|
||||
const user = new UserEntity();
|
||||
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);
|
||||
await user.save();
|
||||
prompt.loginInfo.symbolInfo.userId = user._id;
|
||||
return {
|
||||
success: true,
|
||||
|
|
|
@ -1,47 +1,47 @@
|
|||
/* eslint-disable class-methods-use-this */
|
||||
import type { ResolverInterface } from 'type-graphql';
|
||||
import { ApolloError } from 'apollo-server-fastify';
|
||||
import {
|
||||
Arg, Ctx, FieldResolver, Query, Resolver, Root,
|
||||
Arg, Ctx, Query, Resolver,
|
||||
} from 'type-graphql';
|
||||
import database from '../../../../database/database';
|
||||
import ApplicationEntity from '../../../../database/entities/application';
|
||||
import ClientEntity from '../../../../database/entities/client';
|
||||
import DeveloperEntity from '../../../../database/entities/developer';
|
||||
import { getUser } from '../../../../graphql/github/sdk';
|
||||
import { UnknownPromptError } from '../../errors';
|
||||
import PromptInfo from '../../models/prompt-info';
|
||||
import type PromptInfoApplication from '../../models/prompt-info-application';
|
||||
import type { WebsiteAPIContext } from '../../types';
|
||||
|
||||
@Resolver(PromptInfo)
|
||||
export default class PromptInfoResolver implements ResolverInterface<PromptInfo> {
|
||||
export default class PromptInfoResolver {
|
||||
@Query(() => PromptInfo)
|
||||
public promptInfo(
|
||||
public async promptInfo(
|
||||
@Arg('promptId') promptId: string,
|
||||
@Ctx() { sessionData }: WebsiteAPIContext,
|
||||
): Partial<PromptInfo> {
|
||||
): Promise<Partial<PromptInfo>> {
|
||||
const prompt = sessionData.authPrompts.get(promptId);
|
||||
if (!prompt) throw new UnknownPromptError();
|
||||
return {
|
||||
id: promptId,
|
||||
clientId: prompt.clientId,
|
||||
scopes: prompt.scopes,
|
||||
studentsMode: prompt.studentsMode,
|
||||
};
|
||||
}
|
||||
|
||||
@FieldResolver()
|
||||
public async application(@Root() prompt: PromptInfo): Promise<PromptInfoApplication> {
|
||||
const application = await database.applicationRepo.findOne({
|
||||
const client = await ClientEntity.findOne({
|
||||
where: {
|
||||
clientId: prompt.clientId,
|
||||
},
|
||||
});
|
||||
if (!application) throw new Error('Prompt data not found');
|
||||
if (!client) throw new ApolloError('Client not found');
|
||||
const application = await ApplicationEntity.findOne(client.applicationId);
|
||||
if (!application) throw new ApolloError('Application not found');
|
||||
const developer = await DeveloperEntity.findOne(application.developerId);
|
||||
if (!developer) throw new ApolloError('Developer not found');
|
||||
return {
|
||||
id: promptId,
|
||||
scopes: prompt.scopes,
|
||||
studentsMode: prompt.studentsMode,
|
||||
application: {
|
||||
name: application.name,
|
||||
iconUrl: application.iconUrl,
|
||||
iconColor: application.iconColor,
|
||||
verified: application.verified,
|
||||
homepage: application.homepage,
|
||||
owner: await getUser(application.ownerGitHubLogin),
|
||||
developer: await getUser(developer.gitHubLogin),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,8 +7,7 @@ import _ from 'lodash';
|
|||
import {
|
||||
Arg, Ctx, Mutation, Resolver,
|
||||
} from 'type-graphql';
|
||||
import database from '../../../../database/database';
|
||||
import User from '../../../../database/entities/user';
|
||||
import UserEntity from '../../../../database/entities/user';
|
||||
import { decryptSymmetrical, decryptWithPrivateKey, encryptSymmetrical } from '../../../../utils';
|
||||
import { InvalidSymbolError, UnknownPromptError } from '../../errors';
|
||||
import type LoginStudent from '../../models/login-student';
|
||||
|
@ -59,12 +58,12 @@ export default class SetSymbolResolver {
|
|||
}));
|
||||
|
||||
const reportingUnits = await client.getReportingUnits();
|
||||
const loginIds = reportingUnits.map((unit) => User.getLoginId(unit.senderId, unit.unitId));
|
||||
const loginIds = reportingUnits.map((unit) => UserEntity.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({
|
||||
const user = await UserEntity.findOne({
|
||||
where: {
|
||||
host: prompt.loginInfo.host,
|
||||
symbol,
|
||||
|
|
|
@ -0,0 +1,97 @@
|
|||
/* 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';
|
||||
|
||||
@Resolver()
|
||||
export default class ApplicationResolver {
|
||||
@Mutation(() => Application)
|
||||
public async createApplication(
|
||||
@Arg('name') name: string,
|
||||
@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');
|
||||
|
||||
const application = new ApplicationEntity();
|
||||
application.developerId = sessionData.loginState.developerId;
|
||||
application.homepage = null;
|
||||
application.verified = false;
|
||||
application.iconUrl = null;
|
||||
application.iconColor = '#444444';
|
||||
application.name = name;
|
||||
await application.save();
|
||||
|
||||
return Application.fromEntity(application);
|
||||
}
|
||||
|
||||
@Query(() => [Application])
|
||||
public async applications(
|
||||
@Ctx() { sessionData }: WebsiteAPIContext,
|
||||
): Promise<Application[]> {
|
||||
if (!sessionData.loginState) throw new UnauthorizedError();
|
||||
const applications = await ApplicationEntity.find({
|
||||
where: {
|
||||
developerId: sessionData.loginState.developerId,
|
||||
},
|
||||
});
|
||||
return applications.map((app) => Application.fromEntity(app));
|
||||
}
|
||||
|
||||
@Query(() => Application, {
|
||||
nullable: true,
|
||||
})
|
||||
public async application(
|
||||
@Arg('id') id: string,
|
||||
@Ctx() { sessionData }: WebsiteAPIContext,
|
||||
): Promise<Application | null> {
|
||||
if (!sessionData.loginState) throw new UnauthorizedError();
|
||||
const application = await ApplicationEntity.findOne(id);
|
||||
if (!application) return null;
|
||||
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);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,31 @@
|
|||
/* eslint-disable class-methods-use-this */
|
||||
import { Ctx, Query, Resolver } from 'type-graphql';
|
||||
import DeveloperEntity from '../../../../database/entities/developer';
|
||||
import { getUser } from '../../../../graphql/github/sdk';
|
||||
import LoginState from '../../models/login-state';
|
||||
import type { WebsiteAPIContext } from '../../types';
|
||||
|
||||
@Resolver(LoginState)
|
||||
export default class LoginStateResolver {
|
||||
@Query(() => LoginState, {
|
||||
nullable: true,
|
||||
})
|
||||
public async loginState(
|
||||
@Ctx() { sessionData }: WebsiteAPIContext,
|
||||
): Promise<LoginState | null> {
|
||||
if (!sessionData.loginState) return null;
|
||||
const developer = await DeveloperEntity.findOne(sessionData.loginState.developerId);
|
||||
if (!developer) {
|
||||
console.error('Developer not found');
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
sessionData.loginState = null;
|
||||
return null;
|
||||
}
|
||||
const gitHubUser = await getUser(developer.gitHubLogin);
|
||||
return {
|
||||
login: gitHubUser.login,
|
||||
name: gitHubUser.name,
|
||||
avatarUrl: gitHubUser.avatarUrl,
|
||||
};
|
||||
}
|
||||
}
|
|
@ -1,5 +1,9 @@
|
|||
import type { AuthPrompt } from './types';
|
||||
import type { AuthPrompt, DeveloperLoginState, GitHubAuthorization } from './types';
|
||||
|
||||
export default class SessionData {
|
||||
public authPrompts = new Map<string, AuthPrompt>();
|
||||
|
||||
public gitHubAuthorizations = new Map<string, GitHubAuthorization>();
|
||||
|
||||
public loginState: DeveloperLoginState | null = null;
|
||||
}
|
||||
|
|
|
@ -95,3 +95,11 @@ export interface CodeContent {
|
|||
export interface TokenContent {
|
||||
tk: string;
|
||||
}
|
||||
|
||||
export interface GitHubAuthorization {
|
||||
returnTo: string;
|
||||
}
|
||||
|
||||
export interface DeveloperLoginState {
|
||||
developerId: ObjectID,
|
||||
}
|
||||
|
|
|
@ -1 +0,0 @@
|
|||
VUE_APP_CAPTCHA_SITE_KEY=6LfAxzUaAAAAANF5VLy39hbgx5K6WTQTa2YDdhmC
|
|
@ -15,23 +15,23 @@ module.exports = {
|
|||
'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
|
||||
'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
|
||||
'import/prefer-default-export': ['warn'],
|
||||
"prefer-destructuring": ["error", {
|
||||
"array": false,
|
||||
"object": true
|
||||
}]
|
||||
'prefer-destructuring': ['error', {
|
||||
array: false,
|
||||
object: true,
|
||||
}],
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
files: './src/graphql/generated.ts',
|
||||
rules: {
|
||||
'max-len': ['off']
|
||||
'max-len': ['off'],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: '**/*.vue',
|
||||
rules: {
|
||||
'class-methods-use-this': ['off']
|
||||
}
|
||||
}
|
||||
'class-methods-use-this': ['off'],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
|
@ -1,9 +1,7 @@
|
|||
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
|
||||
- src/graphql/queries/*.ts
|
||||
- src/graphql/mutations/*.ts
|
||||
generates:
|
||||
./src/graphql/generated.ts:
|
||||
plugins:
|
||||
|
|
56
website/package-lock.json
generated
56
website/package-lock.json
generated
|
@ -3870,12 +3870,55 @@
|
|||
}
|
||||
},
|
||||
"@vue/cli-plugin-router": {
|
||||
"version": "4.5.10",
|
||||
"resolved": "https://registry.npmjs.org/@vue/cli-plugin-router/-/cli-plugin-router-4.5.10.tgz",
|
||||
"integrity": "sha512-roiZTx2W59kTRaqNzHEnjnakP89MS+pVf3zWBlwsNXZpQuvqwFvoNfH/nBSJjqGRgZTRtCUe6vGgVPUEFYi/cg==",
|
||||
"version": "4.5.11",
|
||||
"resolved": "https://registry.npmjs.org/@vue/cli-plugin-router/-/cli-plugin-router-4.5.11.tgz",
|
||||
"integrity": "sha512-09tzw3faOs48IUPwLutYaNC7eoyyL140fKruTwdFdXuBLDdSQVida57Brx0zj2UKXc5qF8hk4GoGrOshN0KfNg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@vue/cli-shared-utils": "^4.5.10"
|
||||
"@vue/cli-shared-utils": "^4.5.11"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vue/cli-shared-utils": {
|
||||
"version": "4.5.11",
|
||||
"resolved": "https://registry.npmjs.org/@vue/cli-shared-utils/-/cli-shared-utils-4.5.11.tgz",
|
||||
"integrity": "sha512-+aaQ+ThQG3+WMexfSWNl0y6f43edqVqRNbguE53F3TIH81I7saS5S750ayqXhZs2r6STJJyqorQnKtAWfHo29A==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@hapi/joi": "^15.0.1",
|
||||
"chalk": "^2.4.2",
|
||||
"execa": "^1.0.0",
|
||||
"launch-editor": "^2.2.1",
|
||||
"lru-cache": "^5.1.1",
|
||||
"node-ipc": "^9.1.1",
|
||||
"open": "^6.3.0",
|
||||
"ora": "^3.4.0",
|
||||
"read-pkg": "^5.1.1",
|
||||
"request": "^2.88.2",
|
||||
"semver": "^6.1.0",
|
||||
"strip-ansi": "^6.0.0"
|
||||
}
|
||||
},
|
||||
"lru-cache": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||
"integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"yallist": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"semver": {
|
||||
"version": "6.3.0",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
|
||||
"integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
|
||||
"dev": true
|
||||
},
|
||||
"yallist": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"@vue/cli-plugin-typescript": {
|
||||
|
@ -16095,6 +16138,11 @@
|
|||
"resolved": "https://registry.npmjs.org/vue-recaptcha/-/vue-recaptcha-1.3.0.tgz",
|
||||
"integrity": "sha512-9Qf1niyHq4QbEUhsvdUkS8BoOyhYwpp8v+imUSj67ffDo9RQ6h8Ekq8EGnw/GKViXCwWalp7EEY/n/fOtU0FyA=="
|
||||
},
|
||||
"vue-router": {
|
||||
"version": "3.5.1",
|
||||
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-3.5.1.tgz",
|
||||
"integrity": "sha512-RRQNLT8Mzr8z7eL4p7BtKvRaTSGdCbTy2+Mm5HTJvLGYSSeG9gDzNasJPP/yOYKLy+/cLG/ftrqq5fvkFwBJEw=="
|
||||
},
|
||||
"vue-style-loader": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/vue-style-loader/-/vue-style-loader-4.1.2.tgz",
|
||||
|
|
|
@ -18,6 +18,7 @@
|
|||
"vue-class-component": "^7.2.3",
|
||||
"vue-property-decorator": "^9.1.2",
|
||||
"vue-recaptcha": "^1.3.0",
|
||||
"vue-router": "^3.2.0",
|
||||
"vuetify": "^2.2.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
@ -29,6 +30,7 @@
|
|||
"@typescript-eslint/parser": "^2.33.0",
|
||||
"@vue/cli-plugin-babel": "~4.5.0",
|
||||
"@vue/cli-plugin-eslint": "~4.5.0",
|
||||
"@vue/cli-plugin-router": "^4.5.11",
|
||||
"@vue/cli-plugin-typescript": "~4.5.0",
|
||||
"@vue/cli-service": "~4.5.0",
|
||||
"@vue/eslint-config-airbnb": "^5.0.2",
|
||||
|
|
|
@ -54,7 +54,7 @@
|
|||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
<v-list-item
|
||||
:href="promptInfo.application.owner.url"
|
||||
:href="promptInfo.application.developer.url"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
|
@ -65,14 +65,14 @@
|
|||
<v-list-item-subtitle class="text-overline">
|
||||
Twórca
|
||||
</v-list-item-subtitle>
|
||||
<v-list-item-title v-if="promptInfo.application.owner.name">
|
||||
{{ promptInfo.application.owner.name }}
|
||||
<v-list-item-title v-if="promptInfo.application.developer.name">
|
||||
{{ promptInfo.application.developer.name }}
|
||||
<span class="text--secondary">
|
||||
({{ promptInfo.application.owner.login }})
|
||||
({{ promptInfo.application.developer.login }})
|
||||
</span>
|
||||
</v-list-item-title>
|
||||
<v-list-item-title v-else>
|
||||
{{ promptInfo.application.owner.login }}
|
||||
{{ promptInfo.application.developer.login }}
|
||||
</v-list-item-title>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
|
|
|
@ -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)
|
||||
|
|
93
website/src/compontents/developer/new-app-dialog.vue
Normal file
93
website/src/compontents/developer/new-app-dialog.vue
Normal file
|
@ -0,0 +1,93 @@
|
|||
<template>
|
||||
<v-dialog v-model="value" max-width="450" :persistent="loading">
|
||||
<template #activator="{ on }">
|
||||
<slot :on="on" name="activator" />
|
||||
</template>
|
||||
<v-card>
|
||||
<v-form @submit.prevent="submit">
|
||||
<v-card-title>
|
||||
Create new application
|
||||
</v-card-title>
|
||||
<v-card-text class="pt-4">
|
||||
<v-text-field
|
||||
label="Application name"
|
||||
hint="Users will see this name"
|
||||
persistent-hint
|
||||
autofocus
|
||||
outlined
|
||||
counter="32"
|
||||
:counter-value="(v) => v.trim().length"
|
||||
v-model="name"
|
||||
/>
|
||||
<v-alert type="error" class="mb-0 mt-2" :value="error">
|
||||
An error occured
|
||||
</v-alert>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
color="primary"
|
||||
text
|
||||
@click="value = false"
|
||||
>
|
||||
Cancel
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="primary"
|
||||
:disabled="!valid"
|
||||
:loading="loading"
|
||||
type="submit"
|
||||
>
|
||||
Create
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-form>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue, Watch } from 'vue-property-decorator';
|
||||
import { sdk } from '@/graphql/sdk';
|
||||
|
||||
@Component({
|
||||
name: 'NewAppDialog',
|
||||
})
|
||||
export default class NewAppDialog extends Vue {
|
||||
value = false;
|
||||
|
||||
name = '';
|
||||
|
||||
loading = false;
|
||||
|
||||
error = false;
|
||||
|
||||
@Watch('value')
|
||||
valueChanged(value: boolean) {
|
||||
if (value) return;
|
||||
this.name = '';
|
||||
this.error = false;
|
||||
}
|
||||
|
||||
get valid() {
|
||||
return this.name.trim().length >= 3 && this.name.trim().length <= 32;
|
||||
}
|
||||
|
||||
async submit() {
|
||||
if (!this.valid || this.loading) return;
|
||||
this.loading = true;
|
||||
this.error = false;
|
||||
try {
|
||||
const result = await sdk.CreateApplication({
|
||||
name: this.name.trim(),
|
||||
});
|
||||
await this.$router.push(`/apps/${result.createApplication.id}`);
|
||||
this.value = false;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
this.error = true;
|
||||
}
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
</script>t
|
|
@ -5,7 +5,7 @@
|
|||
<span class="primary--text">Wulkanowy</span> Bridge
|
||||
</div>
|
||||
</div>
|
||||
<v-main class="px-4">
|
||||
<v-main class="px-4 pt-4">
|
||||
<v-sheet max-width="500" class="mx-auto mt-16" color="transparent">
|
||||
<slot />
|
||||
</v-sheet>
|
||||
|
|
|
@ -18,17 +18,23 @@ export type Scalars = {
|
|||
export type Query = {
|
||||
__typename?: 'Query';
|
||||
promptInfo: PromptInfo;
|
||||
applications: Array<Application>;
|
||||
application: Maybe<Application>;
|
||||
loginState: Maybe<LoginState>;
|
||||
};
|
||||
|
||||
export type QueryPromptInfoArgs = {
|
||||
promptId: Scalars['String'];
|
||||
};
|
||||
|
||||
export type QueryApplicationArgs = {
|
||||
id: Scalars['String'];
|
||||
};
|
||||
|
||||
export type PromptInfo = {
|
||||
__typename?: 'PromptInfo';
|
||||
id: Scalars['String'];
|
||||
scopes: Array<Scalars['String']>;
|
||||
clientId: Scalars['String'];
|
||||
studentsMode: StudentsMode;
|
||||
application: PromptInfoApplication;
|
||||
};
|
||||
|
@ -45,7 +51,7 @@ export type PromptInfoApplication = {
|
|||
iconUrl: Maybe<Scalars['String']>;
|
||||
iconColor: Scalars['String'];
|
||||
verified: Scalars['Boolean'];
|
||||
owner: GitHubUser;
|
||||
developer: GitHubUser;
|
||||
homepage: Maybe<Scalars['String']>;
|
||||
};
|
||||
|
||||
|
@ -56,11 +62,30 @@ export type GitHubUser = {
|
|||
url: Scalars['String'];
|
||||
};
|
||||
|
||||
export type Application = {
|
||||
__typename?: 'Application';
|
||||
id: Scalars['String'];
|
||||
name: Scalars['String'];
|
||||
iconUrl: Maybe<Scalars['String']>;
|
||||
iconColor: Scalars['String'];
|
||||
homepage: Maybe<Scalars['String']>;
|
||||
verified: Scalars['Boolean'];
|
||||
};
|
||||
|
||||
export type LoginState = {
|
||||
__typename?: 'LoginState';
|
||||
name: Maybe<Scalars['String']>;
|
||||
login: Scalars['String'];
|
||||
avatarUrl: Scalars['String'];
|
||||
};
|
||||
|
||||
export type Mutation = {
|
||||
__typename?: 'Mutation';
|
||||
createUser: CreateUserResult;
|
||||
login: LoginResult;
|
||||
setSymbol: SetSymbolResult;
|
||||
createApplication: Application;
|
||||
modifyApplication: Application;
|
||||
};
|
||||
|
||||
export type MutationCreateUserArgs = {
|
||||
|
@ -81,6 +106,16 @@ export type MutationSetSymbolArgs = {
|
|||
promptId: Scalars['String'];
|
||||
};
|
||||
|
||||
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'];
|
||||
|
@ -103,6 +138,18 @@ export type LoginStudent = {
|
|||
name: Scalars['String'];
|
||||
};
|
||||
|
||||
export type CreateApplicationMutationVariables = Exact<{
|
||||
name: Scalars['String'];
|
||||
}>;
|
||||
|
||||
export type CreateApplicationMutation = (
|
||||
{ __typename?: 'Mutation' }
|
||||
& { createApplication: (
|
||||
{ __typename?: 'Application' }
|
||||
& Pick<Application, 'id'>
|
||||
); }
|
||||
);
|
||||
|
||||
export type CreateUserMutationVariables = Exact<{
|
||||
promptId: Scalars['String'];
|
||||
email: Scalars['String'];
|
||||
|
@ -132,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'];
|
||||
|
@ -149,6 +210,38 @@ 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 = (
|
||||
{ __typename?: 'Query' }
|
||||
& { applications: Array<(
|
||||
{ __typename?: 'Application' }
|
||||
& Pick<Application, 'id' | 'name' | 'iconUrl' | 'iconColor'>
|
||||
)>; }
|
||||
);
|
||||
|
||||
export type GetLoginStateQueryVariables = Exact<{ [key: string]: never }>;
|
||||
|
||||
export type GetLoginStateQuery = (
|
||||
{ __typename?: 'Query' }
|
||||
& { loginState: Maybe<(
|
||||
{ __typename?: 'LoginState' }
|
||||
& Pick<LoginState, 'name' | 'avatarUrl' | 'login'>
|
||||
)>; }
|
||||
);
|
||||
|
||||
export type GetPromptInfoQueryVariables = Exact<{
|
||||
promptId: Scalars['String'];
|
||||
}>;
|
||||
|
@ -161,7 +254,7 @@ export type GetPromptInfoQuery = (
|
|||
& { application: (
|
||||
{ __typename?: 'PromptInfoApplication' }
|
||||
& Pick<PromptInfoApplication, 'name' | 'iconUrl' | 'iconColor' | 'verified' | 'homepage'>
|
||||
& { owner: (
|
||||
& { developer: (
|
||||
{ __typename?: 'GitHubUser' }
|
||||
& Pick<GitHubUser, 'login' | 'name' | 'url'>
|
||||
); }
|
||||
|
@ -169,6 +262,13 @@ export type GetPromptInfoQuery = (
|
|||
); }
|
||||
);
|
||||
|
||||
export const CreateApplicationDocument = gql`
|
||||
mutation CreateApplication($name: String!) {
|
||||
createApplication(name: $name) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`;
|
||||
export const CreateUserDocument = gql`
|
||||
mutation CreateUser($promptId: String!, $email: String!) {
|
||||
createUser(promptId: $promptId, email: $email) {
|
||||
|
@ -189,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) {
|
||||
|
@ -200,6 +311,36 @@ 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 {
|
||||
id
|
||||
name
|
||||
iconUrl
|
||||
iconColor
|
||||
}
|
||||
}
|
||||
`;
|
||||
export const GetLoginStateDocument = gql`
|
||||
query GetLoginState {
|
||||
loginState {
|
||||
name
|
||||
avatarUrl
|
||||
login
|
||||
}
|
||||
}
|
||||
`;
|
||||
export const GetPromptInfoDocument = gql`
|
||||
query GetPromptInfo($promptId: String!) {
|
||||
promptInfo(promptId: $promptId) {
|
||||
|
@ -212,7 +353,7 @@ export const GetPromptInfoDocument = gql`
|
|||
iconColor
|
||||
verified
|
||||
homepage
|
||||
owner {
|
||||
developer {
|
||||
login
|
||||
name
|
||||
url
|
||||
|
@ -227,15 +368,30 @@ export type SdkFunctionWrapper = <T>(action: () => Promise<T>) => Promise<T>;
|
|||
const defaultWrapper: SdkFunctionWrapper = (sdkFunction) => sdkFunction();
|
||||
export function getSdk(client: GraphQLClient, withWrapper: SdkFunctionWrapper = defaultWrapper) {
|
||||
return {
|
||||
CreateApplication(variables: CreateApplicationMutationVariables, requestHeaders?: Headers): Promise<CreateApplicationMutation> {
|
||||
return withWrapper(() => client.request<CreateApplicationMutation>(print(CreateApplicationDocument), variables, requestHeaders));
|
||||
},
|
||||
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));
|
||||
},
|
||||
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));
|
||||
},
|
||||
GetLoginState(variables?: GetLoginStateQueryVariables, requestHeaders?: Headers): Promise<GetLoginStateQuery> {
|
||||
return withWrapper(() => client.request<GetLoginStateQuery>(print(GetLoginStateDocument), variables, requestHeaders));
|
||||
},
|
||||
GetPromptInfo(variables: GetPromptInfoQueryVariables, requestHeaders?: Headers): Promise<GetPromptInfoQuery> {
|
||||
return withWrapper(() => client.request<GetPromptInfoQuery>(print(GetPromptInfoDocument), variables, requestHeaders));
|
||||
},
|
||||
|
|
8
website/src/graphql/mutations/create-application.ts
Normal file
8
website/src/graphql/mutations/create-application.ts
Normal file
|
@ -0,0 +1,8 @@
|
|||
import gql from 'graphql-tag';
|
||||
|
||||
export default gql`mutation CreateApplication($name: String!) {
|
||||
createApplication(name: $name) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`;
|
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
|
||||
}
|
||||
}`;
|
10
website/src/graphql/queries/get-applications.ts
Normal file
10
website/src/graphql/queries/get-applications.ts
Normal file
|
@ -0,0 +1,10 @@
|
|||
import gql from 'graphql-tag';
|
||||
|
||||
export default gql`query GetApplications {
|
||||
applications {
|
||||
id
|
||||
name
|
||||
iconUrl
|
||||
iconColor
|
||||
}
|
||||
}`;
|
9
website/src/graphql/queries/get-login-state.ts
Normal file
9
website/src/graphql/queries/get-login-state.ts
Normal file
|
@ -0,0 +1,9 @@
|
|||
import gql from 'graphql-tag';
|
||||
|
||||
export default gql`query GetLoginState {
|
||||
loginState {
|
||||
name
|
||||
avatarUrl
|
||||
login
|
||||
}
|
||||
}`;
|
|
@ -11,7 +11,7 @@ export default gql`query GetPromptInfo($promptId: String!) {
|
|||
iconColor
|
||||
verified
|
||||
homepage
|
||||
owner {
|
||||
developer {
|
||||
login
|
||||
name
|
||||
url
|
||||
|
|
80
website/src/pages/app-icon.vue
Normal file
80
website/src/pages/app-icon.vue
Normal file
|
@ -0,0 +1,80 @@
|
|||
<template>
|
||||
<v-sheet
|
||||
:width="circleSize"
|
||||
:height="circleSize"
|
||||
class="app-icon overflow-hidden"
|
||||
outlined
|
||||
>
|
||||
<v-sheet
|
||||
class="fill-height d-flex align-center justify-center"
|
||||
:color="color"
|
||||
>
|
||||
<v-img
|
||||
:src="url"
|
||||
:width="imgSize"
|
||||
:height="imgSize"
|
||||
aspect-ratio="1"
|
||||
contain
|
||||
>
|
||||
<template v-slot:placeholder>
|
||||
<div class="fill-height d-flex align-center justify-center">
|
||||
<v-icon :size="imgSize">
|
||||
mdi-help
|
||||
</v-icon>
|
||||
</div>
|
||||
</template>
|
||||
</v-img>
|
||||
</v-sheet>
|
||||
</v-sheet>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.app-icon {
|
||||
border-radius: 50% !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Prop, Vue } from 'vue-property-decorator';
|
||||
|
||||
@Component({
|
||||
name: 'AppIcon',
|
||||
})
|
||||
export default class AppIcon extends Vue {
|
||||
@Prop({
|
||||
type: String,
|
||||
required: true,
|
||||
})
|
||||
color!: string;
|
||||
|
||||
@Prop({
|
||||
type: String,
|
||||
default: null,
|
||||
})
|
||||
url!: string | null
|
||||
|
||||
@Prop({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
})
|
||||
large!: boolean
|
||||
|
||||
@Prop({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
})
|
||||
small!: boolean
|
||||
|
||||
get circleSize() {
|
||||
if (this.large) return 128;
|
||||
if (this.small) return 64;
|
||||
return 96;
|
||||
}
|
||||
|
||||
get imgSize() {
|
||||
if (this.large) return 80;
|
||||
if (this.small) return 40;
|
||||
return 60;
|
||||
}
|
||||
}
|
||||
</script>
|
|
@ -16,7 +16,7 @@
|
|||
</div>
|
||||
<template v-else>
|
||||
<v-card outlined>
|
||||
<div class="d-flex justify-center mn-16 avatar__wrapper">
|
||||
<div class="d-flex justify-center app-icon-wrapper">
|
||||
<v-badge
|
||||
:color="promptInfo.application.verified ? 'green' : 'grey'"
|
||||
offset-x="64"
|
||||
|
@ -26,34 +26,13 @@
|
|||
:value="step === 'overview'"
|
||||
>
|
||||
<transition name="scale">
|
||||
<v-sheet
|
||||
<app-icon
|
||||
v-if="step === 'overview'"
|
||||
width="128"
|
||||
height="128"
|
||||
class="avatar-sheet mx-4 overflow-hidden"
|
||||
outlined
|
||||
>
|
||||
<v-sheet
|
||||
class="fill-height d-flex align-center justify-center"
|
||||
class="mx-4"
|
||||
:color="promptInfo.application.iconColor"
|
||||
>
|
||||
<v-img
|
||||
:src="promptInfo.application.iconUrl"
|
||||
width="80"
|
||||
height="80"
|
||||
aspect-ratio="1"
|
||||
contain
|
||||
>
|
||||
<template v-slot:placeholder>
|
||||
<div class="fill-height d-flex align-center justify-center">
|
||||
<v-icon :size="80">
|
||||
mdi-help
|
||||
</v-icon>
|
||||
</div>
|
||||
</template>
|
||||
</v-img>
|
||||
</v-sheet>
|
||||
</v-sheet>
|
||||
:url="promptInfo.application.iconUrl"
|
||||
large
|
||||
/>
|
||||
</transition>
|
||||
</v-badge>
|
||||
</div>
|
||||
|
@ -106,15 +85,11 @@
|
|||
|
||||
<style lang="scss">
|
||||
.authenticate-prompt-app {
|
||||
.avatar-sheet {
|
||||
border-radius: 50% !important;
|
||||
}
|
||||
|
||||
.fill-height {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.avatar__wrapper {
|
||||
.app-icon-wrapper {
|
||||
position: absolute;
|
||||
top: -64px;
|
||||
width: 100%;
|
||||
|
@ -135,15 +110,17 @@ 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';
|
||||
import IsEmail from 'isemail';
|
||||
import AppIcon from '@/pages/app-icon.vue';
|
||||
|
||||
@Component({
|
||||
name: 'AuthenticatePromptApp',
|
||||
components: {
|
||||
AppIcon,
|
||||
SymbolsWindow,
|
||||
EmailWindow,
|
||||
LoginWindow,
|
||||
|
|
88
website/src/pages/developer/app.vue
Normal file
88
website/src/pages/developer/app.vue
Normal file
|
@ -0,0 +1,88 @@
|
|||
<template>
|
||||
<v-app class="developer-app">
|
||||
<v-container class="d-flex align-center justify-center fill-height" v-if="loginStateLoading">
|
||||
<v-progress-circular indeterminate color="primary" :size="96" />
|
||||
</v-container>
|
||||
<developer-signed-out v-else-if="loginState === null" />
|
||||
<div v-else>
|
||||
<v-app-bar app color="primary" dark>
|
||||
<v-btn icon to="/" v-if="$route.name !== 'Home'">
|
||||
<v-icon>mdi-arrow-left</v-icon>
|
||||
</v-btn>
|
||||
<v-app-bar-title>Developer</v-app-bar-title>
|
||||
<v-spacer />
|
||||
<v-menu offset-y nudge-bottom="12" min-width="350">
|
||||
<template #activator="{ on }">
|
||||
<v-btn icon v-on="on">
|
||||
<v-avatar>
|
||||
<v-img :src="loginState.avatarUrl" :width="48" />
|
||||
</v-avatar>
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-card outlined>
|
||||
<v-card-title v-if="loginState.name" class="d-block">
|
||||
{{ loginState.name }}
|
||||
<span class="text--secondary">
|
||||
({{ loginState.login }})
|
||||
</span>
|
||||
</v-card-title>
|
||||
<v-card-title v-else>
|
||||
{{ loginState.login }}
|
||||
</v-card-title>
|
||||
<v-card-subtitle>Developer account</v-card-subtitle>
|
||||
<v-divider />
|
||||
<v-card-actions>
|
||||
<v-btn
|
||||
block
|
||||
color="primary"
|
||||
outlined
|
||||
href="/api/website/developer/sign-out"
|
||||
>
|
||||
Sign out
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-menu>
|
||||
</v-app-bar>
|
||||
<v-main>
|
||||
<router-view :login-state="loginState" />
|
||||
</v-main>
|
||||
</div>
|
||||
</v-app>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.developer-app {
|
||||
background-color: #f7f7f7 !important;
|
||||
}
|
||||
|
||||
.v-card__text, .v-card__title {
|
||||
word-break: normal;
|
||||
}
|
||||
|
||||
.no-basis {
|
||||
flex-basis: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from 'vue-property-decorator';
|
||||
import DeveloperSignedOut from '@/pages/developer/views/signed-out.vue';
|
||||
import { LoginState } from '@/graphql/generated';
|
||||
import { sdk } from '@/graphql/sdk';
|
||||
|
||||
@Component({
|
||||
name: 'DeveloperApp',
|
||||
components: { DeveloperSignedOut },
|
||||
})
|
||||
export default class DeveloperApp extends Vue {
|
||||
loginStateLoading = true;
|
||||
|
||||
loginState: LoginState | null = null;
|
||||
|
||||
async created() {
|
||||
this.loginState = (await sdk.GetLoginState()).loginState;
|
||||
this.loginStateLoading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
12
website/src/pages/developer/main.ts
Normal file
12
website/src/pages/developer/main.ts
Normal file
|
@ -0,0 +1,12 @@
|
|||
import Vue from 'vue';
|
||||
import vuetify from '@/plugins/vuetify';
|
||||
import DeveloperApp from './app.vue';
|
||||
import router from './router';
|
||||
|
||||
Vue.config.productionTip = false;
|
||||
|
||||
new Vue({
|
||||
vuetify,
|
||||
router,
|
||||
render: (h) => h(DeveloperApp),
|
||||
}).$mount('#app');
|
29
website/src/pages/developer/router.ts
Normal file
29
website/src/pages/developer/router.ts
Normal file
|
@ -0,0 +1,29 @@
|
|||
import Vue from 'vue';
|
||||
import VueRouter, { RouteConfig } from 'vue-router';
|
||||
|
||||
Vue.use(VueRouter);
|
||||
|
||||
const routes: Array<RouteConfig> = [
|
||||
{
|
||||
path: '/',
|
||||
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: '/',
|
||||
},
|
||||
];
|
||||
|
||||
const router = new VueRouter({
|
||||
mode: 'history',
|
||||
base: `${process.env.BASE_URL}developer`,
|
||||
routes,
|
||||
});
|
||||
|
||||
export default router;
|
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>
|
99
website/src/pages/developer/views/home.vue
Normal file
99
website/src/pages/developer/views/home.vue
Normal file
|
@ -0,0 +1,99 @@
|
|||
<template>
|
||||
<v-container class="home-container">
|
||||
<div class="text-h4 my-8">
|
||||
Your applications
|
||||
</div>
|
||||
<div class="applications">
|
||||
<new-app-dialog>
|
||||
<template #activator="{ on }">
|
||||
<v-card
|
||||
outlined
|
||||
class="d-flex flex-column align-center justify-center text-center"
|
||||
color="primary--text"
|
||||
v-on="on"
|
||||
>
|
||||
<v-icon color="primary" :size="64">mdi-plus</v-icon>
|
||||
<div class="text-h5 my-2">New app</div>
|
||||
</v-card>
|
||||
</template>
|
||||
</new-app-dialog>
|
||||
<v-card
|
||||
v-if="applicationError"
|
||||
outlined
|
||||
color="red--text"
|
||||
class="d-flex flex-column align-center justify-center text-center"
|
||||
>
|
||||
<div class="text-h6 px-2">Failed to load app list</div>
|
||||
<v-btn class="mt-4" color="primary" @click="loadApplications">Retry</v-btn>
|
||||
</v-card>
|
||||
<v-skeleton-loader type="image" v-else-if="applications === null" height="200" />
|
||||
<v-card
|
||||
v-else
|
||||
v-for="app in applications"
|
||||
:key="app.id"
|
||||
outlined
|
||||
:to="`/apps/${app.id}`"
|
||||
class="d-flex flex-column align-center py-2 text-center"
|
||||
:style="{
|
||||
'color': app.iconColor
|
||||
}"
|
||||
>
|
||||
<app-icon
|
||||
:color="app.iconColor"
|
||||
:url="app.iconUrl"
|
||||
class="mx-2"
|
||||
/>
|
||||
<v-spacer />
|
||||
<div class="text-h5 my-2 text--secondary px-2">{{ app.name }}</div>
|
||||
<v-spacer />
|
||||
</v-card>
|
||||
</div>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.home-container {
|
||||
max-width: 800px;
|
||||
|
||||
.applications {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(250px, 100%), 1fr));
|
||||
grid-auto-rows: minmax(200px, 1fr);
|
||||
grid-gap: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from 'vue-property-decorator';
|
||||
import AppIcon from '@/pages/app-icon.vue';
|
||||
import NewAppDialog from '@/compontents/developer/new-app-dialog.vue';
|
||||
import { GetApplicationsQuery } from '@/graphql/generated';
|
||||
import { sdk } from '@/graphql/sdk';
|
||||
|
||||
@Component({
|
||||
name: 'DeveloperHome',
|
||||
components: { NewAppDialog, AppIcon },
|
||||
})
|
||||
export default class DeveloperHome extends Vue {
|
||||
applications: GetApplicationsQuery['applications'] | null = null;
|
||||
|
||||
applicationError = false;
|
||||
|
||||
async loadApplications() {
|
||||
this.applications = null;
|
||||
this.applicationError = false;
|
||||
try {
|
||||
const result = await sdk.GetApplications();
|
||||
this.applications = result.applications;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
this.applicationError = true;
|
||||
}
|
||||
}
|
||||
|
||||
created() {
|
||||
this.loadApplications();
|
||||
}
|
||||
}
|
||||
</script>
|
54
website/src/pages/developer/views/signed-out.vue
Normal file
54
website/src/pages/developer/views/signed-out.vue
Normal file
|
@ -0,0 +1,54 @@
|
|||
<template>
|
||||
<div>
|
||||
<div>
|
||||
<div class="text-h4 text-center mt-8 mx-2">
|
||||
<span class="primary--text">Wulkanowy</span> Bridge<br>
|
||||
<span class="text--secondary">Developer Console</span>
|
||||
</div>
|
||||
</div>
|
||||
<v-main class="px-4">
|
||||
<v-sheet max-width="500" class="mx-auto mt-12" color="transparent">
|
||||
<v-btn block large dark :href="signInUrl">
|
||||
<v-icon left>mdi-login</v-icon>
|
||||
<v-spacer />
|
||||
Sign in with GitHub
|
||||
<v-spacer />
|
||||
</v-btn>
|
||||
<v-btn block outlined href="/" class="mt-2">
|
||||
<v-icon left>mdi-arrow-left</v-icon>
|
||||
<v-spacer />
|
||||
Go back
|
||||
<v-spacer />
|
||||
</v-btn>
|
||||
<v-divider class="my-8 mx-4" />
|
||||
<v-btn block color="primary" outlined href="https://github.com/wulkanowy/bridge/wiki">
|
||||
<v-icon left>mdi-book</v-icon>
|
||||
<v-spacer />
|
||||
Documentation
|
||||
<v-spacer />
|
||||
</v-btn>
|
||||
<v-btn class="mt-2" block color="primary" outlined href="https://github.com/wulkanowy/bridge">
|
||||
<v-icon left>mdi-github</v-icon>
|
||||
<v-spacer />
|
||||
Source code
|
||||
<v-spacer />
|
||||
</v-btn>
|
||||
</v-sheet>
|
||||
</v-main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from 'vue-property-decorator';
|
||||
|
||||
@Component({
|
||||
name: 'DeveloperSignedOut',
|
||||
})
|
||||
export default class DeveloperSignedOut extends Vue {
|
||||
get signInUrl() {
|
||||
const url = new URL('/api/website/developer/sign-in/github', window.location.toString());
|
||||
url.searchParams.set('return_to', `${window.location.pathname}${window.location.search}${window.location.hash}`);
|
||||
return url.toString();
|
||||
}
|
||||
}
|
||||
</script>
|
|
@ -19,7 +19,7 @@ export interface PromptInfo {
|
|||
iconColor: string;
|
||||
verified: boolean;
|
||||
homepage: string | null;
|
||||
owner: {
|
||||
developer: {
|
||||
login: string;
|
||||
name: string | null;
|
||||
url: string;
|
||||
|
|
|
@ -15,5 +15,11 @@ module.exports = {
|
|||
filename: 'prompt-error.html',
|
||||
title: 'Błąd autoryzacji | Wulkanowy Bridge',
|
||||
},
|
||||
developer: {
|
||||
entry: 'src/pages/developer/main.ts',
|
||||
template: '/public/index.html',
|
||||
filename: 'developer.html',
|
||||
title: 'Konsola dewelopera | Wulkanowy Bridge',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
Loading…
Reference in a new issue