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": "eslint src -c .eslintrc.json --ext .ts",
|
||||||
"lint:fix": "eslint src -c .eslintrc.json --ext .ts --fix",
|
"lint:fix": "eslint src -c .eslintrc.json --ext .ts --fix",
|
||||||
"start": "ts-node .",
|
"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>",
|
"author": "Dominik Korsa <dominik.korsa@gmail.com>",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
@ -49,6 +49,6 @@
|
||||||
"eslint-config-airbnb": "^18.2.1",
|
"eslint-config-airbnb": "^18.2.1",
|
||||||
"eslint-config-airbnb-typescript": "^12.0.0",
|
"eslint-config-airbnb-typescript": "^12.0.0",
|
||||||
"eslint-plugin-import": "^2.22.1",
|
"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 { createConnection } from 'typeorm';
|
||||||
import Application from './entities/application';
|
import { requireEnv } from '../utils';
|
||||||
import Token from './entities/token';
|
import ApplicationEntity from './entities/application';
|
||||||
import User from './entities/user';
|
import ClientEntity from './entities/client';
|
||||||
|
import DeveloperEntity from './entities/developer';
|
||||||
|
import TokenEntity from './entities/token';
|
||||||
|
import UserEntity from './entities/user';
|
||||||
|
|
||||||
class Database {
|
class Database {
|
||||||
private connection!: Connection;
|
private connection!: Connection;
|
||||||
|
|
||||||
public applicationRepo!: Repository<Application>;
|
|
||||||
|
|
||||||
public userRepo!: Repository<User>;
|
|
||||||
|
|
||||||
public tokenRepo!: Repository<Token>;
|
|
||||||
|
|
||||||
public async connect(): Promise<void> {
|
public async connect(): Promise<void> {
|
||||||
this.connection = await createConnection({
|
this.connection = await createConnection({
|
||||||
type: 'mongodb',
|
type: 'mongodb',
|
||||||
host: 'localhost',
|
url: requireEnv('DATABASE_URL'),
|
||||||
port: 27017,
|
useNewUrlParser: true,
|
||||||
database: 'wulkanowy-bridge-local',
|
|
||||||
entities: [
|
entities: [
|
||||||
Application,
|
ApplicationEntity,
|
||||||
User,
|
UserEntity,
|
||||||
Token,
|
TokenEntity,
|
||||||
|
DeveloperEntity,
|
||||||
|
ClientEntity,
|
||||||
],
|
],
|
||||||
useUnifiedTopology: true,
|
useUnifiedTopology: true,
|
||||||
logging: false,
|
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 type { ObjectID } from 'typeorm';
|
||||||
import {
|
import {
|
||||||
BaseEntity,
|
BaseEntity,
|
||||||
Column, Entity, ObjectIdColumn,
|
Column, Entity, ObjectIdColumn,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
|
|
||||||
@Entity()
|
@Entity({
|
||||||
export default class Application extends BaseEntity {
|
name: 'applications',
|
||||||
|
})
|
||||||
|
export default class ApplicationEntity extends BaseEntity {
|
||||||
@ObjectIdColumn()
|
@ObjectIdColumn()
|
||||||
public _id!: ObjectID;
|
public _id!: ObjectID;
|
||||||
|
|
||||||
@Column()
|
|
||||||
public clientId!: string;
|
|
||||||
|
|
||||||
@Column()
|
|
||||||
public clientSecret!: string;
|
|
||||||
|
|
||||||
@Column()
|
@Column()
|
||||||
public name!: string;
|
public name!: string;
|
||||||
|
|
||||||
|
@ -29,15 +24,8 @@ export default class Application extends BaseEntity {
|
||||||
public verified!: boolean;
|
public verified!: boolean;
|
||||||
|
|
||||||
@Column()
|
@Column()
|
||||||
public redirectUris!: string[];
|
public developerId!: ObjectID;
|
||||||
|
|
||||||
@Column()
|
|
||||||
public ownerGitHubLogin!: string;
|
|
||||||
|
|
||||||
@Column()
|
@Column()
|
||||||
public homepage!: string | null;
|
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,
|
BaseEntity, Column, Entity, ObjectIdColumn,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
|
|
||||||
@Entity()
|
@Entity({
|
||||||
export default class Token extends BaseEntity {
|
name: 'tokens',
|
||||||
|
})
|
||||||
|
export default class TokenEntity extends BaseEntity {
|
||||||
@ObjectIdColumn()
|
@ObjectIdColumn()
|
||||||
public _id!: ObjectID;
|
public _id!: ObjectID;
|
||||||
|
|
||||||
|
|
|
@ -3,8 +3,10 @@ import {
|
||||||
BaseEntity, Column, Entity, ObjectIdColumn,
|
BaseEntity, Column, Entity, ObjectIdColumn,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
|
|
||||||
@Entity()
|
@Entity({
|
||||||
export default class User extends BaseEntity {
|
name: 'users',
|
||||||
|
})
|
||||||
|
export default class UserEntity extends BaseEntity {
|
||||||
@ObjectIdColumn()
|
@ObjectIdColumn()
|
||||||
public _id!: ObjectID;
|
public _id!: ObjectID;
|
||||||
|
|
||||||
|
|
|
@ -5,6 +5,7 @@ export const getUserQuery = gql`query GetUser($login: String!) {
|
||||||
login
|
login
|
||||||
name
|
name
|
||||||
url
|
url
|
||||||
|
avatarUrl
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
@ -13,6 +14,7 @@ export interface User {
|
||||||
login: string;
|
login: string;
|
||||||
name: string | null;
|
name: string | null;
|
||||||
url: string;
|
url: string;
|
||||||
|
avatarUrl: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GetUserQueryResult {
|
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 { requireEnv } from '../../utils';
|
||||||
import type { GetUserQueryResult, User } from './queries/get-user';
|
import type { GetUserQueryResult, User } from './queries/get-user';
|
||||||
import { getUserQuery } 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');
|
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> {
|
export async function getUser(login: string): Promise<User> {
|
||||||
const { user } = await client.request<GetUserQueryResult>(getUserQuery, {
|
const { user } = await client.request<GetUserQueryResult>(getUserQuery, {
|
||||||
login,
|
login,
|
||||||
|
}, {
|
||||||
|
Authorization: `bearer ${requireEnv('GITHUB_API_TOKEN')}`,
|
||||||
});
|
});
|
||||||
return user;
|
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 { nanoid } from 'nanoid';
|
||||||
import urlJoin from 'url-join';
|
import urlJoin from 'url-join';
|
||||||
import { scopes, websitePrefix } from '../../constants';
|
import { scopes, websitePrefix } from '../../constants';
|
||||||
import database from '../../database/database';
|
import ClientEntity from '../../database/entities/client';
|
||||||
import { ParamError, ScopeError } from '../../errors';
|
import { ParamError, ScopeError } from '../../errors';
|
||||||
import type { MyFastifyInstance, StudentsMode } from '../../types';
|
import type { MyFastifyInstance, StudentsMode } from '../../types';
|
||||||
|
|
||||||
|
@ -42,16 +42,16 @@ export default function registerAuthorize(server: MyFastifyInstance): void {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const application = await database.applicationRepo.findOne({
|
const client = await ClientEntity.findOne({
|
||||||
where: {
|
where: {
|
||||||
clientId: request.query.client_id,
|
clientId: request.query.client_id,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (application === undefined) {
|
if (client === undefined) {
|
||||||
await reply.redirect(urlJoin(websitePrefix, '/prompt-error?code=unknown_application'));
|
await reply.redirect(urlJoin(websitePrefix, '/prompt-error?code=unknown_application'));
|
||||||
return;
|
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'));
|
await reply.redirect(urlJoin(websitePrefix, '/prompt-error?code=unknown_redirect_uri'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -88,7 +88,7 @@ export default function registerAuthorize(server: MyFastifyInstance): void {
|
||||||
|
|
||||||
const sessionData = getSessionData(request.session);
|
const sessionData = getSessionData(request.session);
|
||||||
sessionData.authPrompts.set(promptId, {
|
sessionData.authPrompts.set(promptId, {
|
||||||
clientId: request.query.client_id,
|
clientId: client.clientId,
|
||||||
redirectUri: request.query.redirect_uri,
|
redirectUri: request.query.redirect_uri,
|
||||||
scopes: requestedScopes,
|
scopes: requestedScopes,
|
||||||
state: request.query.state,
|
state: request.query.state,
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import type { FastifyReply } from 'fastify';
|
import type { FastifyReply } from 'fastify';
|
||||||
import { getCode, invalidateCode } from '../../codes';
|
import { getCode, invalidateCode } from '../../codes';
|
||||||
import database from '../../database/database';
|
import ClientEntity from '../../database/entities/client';
|
||||||
import Token from '../../database/entities/token';
|
import TokenEntity from '../../database/entities/token';
|
||||||
import { ParamError } from '../../errors';
|
import { ParamError } from '../../errors';
|
||||||
import type { CodeInfo, MyFastifyInstance, TokenContent } from '../../types';
|
import type { CodeInfo, MyFastifyInstance, TokenContent } from '../../types';
|
||||||
|
|
||||||
|
@ -67,13 +67,13 @@ export default function registerToken(server: MyFastifyInstance): void {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const application = await database.applicationRepo.findOne({
|
const client = await ClientEntity.findOne({
|
||||||
where: {
|
where: {
|
||||||
clientId: request.body.client_id,
|
clientId: request.body.client_id,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!application) {
|
if (!client) {
|
||||||
await sendCustomError(reply, 'invalid_client', 'Application not found', 401);
|
await sendCustomError(reply, 'invalid_client', 'Client id not found', 401);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -96,15 +96,15 @@ export default function registerToken(server: MyFastifyInstance): void {
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
validateParam('client_secret', request.body.client_secret);
|
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);
|
await sendCustomError(reply, 'invalid_client', 'Invalid client secret', 401);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const tokenId = Token.generateTokenId();
|
const tokenId = TokenEntity.generateTokenId();
|
||||||
|
|
||||||
const token = new Token();
|
const token = new TokenEntity();
|
||||||
token.tokenId = tokenId;
|
token.tokenId = tokenId;
|
||||||
token.creationDate = new Date();
|
token.creationDate = new Date();
|
||||||
token.clientId = codeInfo.clientId;
|
token.clientId = codeInfo.clientId;
|
||||||
|
@ -117,7 +117,7 @@ export default function registerToken(server: MyFastifyInstance): void {
|
||||||
token.encryptedSDK = codeInfo.encryptedSDK;
|
token.encryptedSDK = codeInfo.encryptedSDK;
|
||||||
token.publicKey = codeInfo.publicKey;
|
token.publicKey = codeInfo.publicKey;
|
||||||
|
|
||||||
await database.tokenRepo.save(token);
|
await token.save();
|
||||||
|
|
||||||
const content: TokenContent = {
|
const content: TokenContent = {
|
||||||
tk: tokenKey,
|
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');
|
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 { getSessionData } from '../../utils';
|
||||||
import registerAllow from './allow';
|
import registerAllow from './allow';
|
||||||
import registerDeny from './deny';
|
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 CreateUserResolver from './resolvers/authenticate-prompt/create-user-resolver';
|
||||||
import LoginResolver from './resolvers/authenticate-prompt/login-resolver';
|
import LoginResolver from './resolvers/authenticate-prompt/login-resolver';
|
||||||
import PromptInfoResolver from './resolvers/authenticate-prompt/prompt-info-resolver';
|
import PromptInfoResolver from './resolvers/authenticate-prompt/prompt-info-resolver';
|
||||||
import SetSymbolResolver from './resolvers/authenticate-prompt/set-symbol-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';
|
import type { WebsiteAPIContext } from './types';
|
||||||
|
|
||||||
export default async function registerWebsiteApi(server: MyFastifyInstance): Promise<void> {
|
export default async function registerWebsiteApi(server: MyFastifyInstance): Promise<void> {
|
||||||
|
@ -18,6 +23,8 @@ export default async function registerWebsiteApi(server: MyFastifyInstance): Pro
|
||||||
LoginResolver,
|
LoginResolver,
|
||||||
SetSymbolResolver,
|
SetSymbolResolver,
|
||||||
CreateUserResolver,
|
CreateUserResolver,
|
||||||
|
LoginStateResolver,
|
||||||
|
ApplicationResolver,
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
const apolloServer = new ApolloServer({
|
const apolloServer = new ApolloServer({
|
||||||
|
@ -35,4 +42,8 @@ export default async function registerWebsiteApi(server: MyFastifyInstance): Pro
|
||||||
|
|
||||||
registerDeny(server);
|
registerDeny(server);
|
||||||
registerAllow(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;
|
public verified!: boolean;
|
||||||
|
|
||||||
@Field(() => GitHubUser)
|
@Field(() => GitHubUser)
|
||||||
public owner!: GitHubUser;
|
public developer!: GitHubUser;
|
||||||
|
|
||||||
@Field(() => String, {
|
@Field(() => String, {
|
||||||
nullable: true,
|
nullable: true,
|
||||||
|
|
|
@ -10,9 +10,6 @@ export default class PromptInfo {
|
||||||
@Field(() => [String])
|
@Field(() => [String])
|
||||||
public scopes!: string[];
|
public scopes!: string[];
|
||||||
|
|
||||||
@Field(() => String)
|
|
||||||
public clientId!: string;
|
|
||||||
|
|
||||||
@Field(() => StudentsMode)
|
@Field(() => StudentsMode)
|
||||||
public studentsMode!: StudentsMode;
|
public studentsMode!: StudentsMode;
|
||||||
|
|
||||||
|
|
|
@ -3,8 +3,7 @@ import { UserInputError } from 'apollo-server-fastify';
|
||||||
import {
|
import {
|
||||||
Arg, Ctx, Mutation, Resolver,
|
Arg, Ctx, Mutation, Resolver,
|
||||||
} from 'type-graphql';
|
} from 'type-graphql';
|
||||||
import database from '../../../../database/database';
|
import UserEntity from '../../../../database/entities/user';
|
||||||
import User from '../../../../database/entities/user';
|
|
||||||
import { UnknownPromptError } from '../../errors';
|
import { UnknownPromptError } from '../../errors';
|
||||||
import CreateUserResult from '../../models/create-user-result';
|
import CreateUserResult from '../../models/create-user-result';
|
||||||
import type { WebsiteAPIContext } from '../../types';
|
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) throw new UserInputError('Login data not provided');
|
||||||
if (!prompt.loginInfo.symbolInfo) throw new UserInputError('Symbol not provided');
|
if (!prompt.loginInfo.symbolInfo) throw new UserInputError('Symbol not provided');
|
||||||
|
|
||||||
const existingUser = await database.userRepo.findOne({
|
const existingUser = await UserEntity.findOne({
|
||||||
where: {
|
where: {
|
||||||
host: prompt.loginInfo.host,
|
host: prompt.loginInfo.host,
|
||||||
symbol: prompt.loginInfo.symbolInfo.symbol,
|
symbol: prompt.loginInfo.symbolInfo.symbol,
|
||||||
|
@ -33,13 +32,13 @@ export default class CreateUserResolver {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (existingUser !== undefined) throw new UserInputError('User already exists');
|
if (existingUser !== undefined) throw new UserInputError('User already exists');
|
||||||
const user = new User();
|
const user = new UserEntity();
|
||||||
user.host = prompt.loginInfo.host;
|
user.host = prompt.loginInfo.host;
|
||||||
user.symbol = prompt.loginInfo.symbolInfo.symbol;
|
user.symbol = prompt.loginInfo.symbolInfo.symbol;
|
||||||
user.username = prompt.loginInfo.username;
|
user.username = prompt.loginInfo.username;
|
||||||
user.loginIds = prompt.loginInfo.symbolInfo.loginIds;
|
user.loginIds = prompt.loginInfo.symbolInfo.loginIds;
|
||||||
user.email = email;
|
user.email = email;
|
||||||
await database.userRepo.save(user);
|
await user.save();
|
||||||
prompt.loginInfo.symbolInfo.userId = user._id;
|
prompt.loginInfo.symbolInfo.userId = user._id;
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
|
|
|
@ -1,47 +1,47 @@
|
||||||
/* eslint-disable class-methods-use-this */
|
/* eslint-disable class-methods-use-this */
|
||||||
import type { ResolverInterface } from 'type-graphql';
|
import { ApolloError } from 'apollo-server-fastify';
|
||||||
import {
|
import {
|
||||||
Arg, Ctx, FieldResolver, Query, Resolver, Root,
|
Arg, Ctx, Query, Resolver,
|
||||||
} from 'type-graphql';
|
} 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 { getUser } from '../../../../graphql/github/sdk';
|
||||||
import { UnknownPromptError } from '../../errors';
|
import { UnknownPromptError } from '../../errors';
|
||||||
import PromptInfo from '../../models/prompt-info';
|
import PromptInfo from '../../models/prompt-info';
|
||||||
import type PromptInfoApplication from '../../models/prompt-info-application';
|
|
||||||
import type { WebsiteAPIContext } from '../../types';
|
import type { WebsiteAPIContext } from '../../types';
|
||||||
|
|
||||||
@Resolver(PromptInfo)
|
@Resolver(PromptInfo)
|
||||||
export default class PromptInfoResolver implements ResolverInterface<PromptInfo> {
|
export default class PromptInfoResolver {
|
||||||
@Query(() => PromptInfo)
|
@Query(() => PromptInfo)
|
||||||
public promptInfo(
|
public async promptInfo(
|
||||||
@Arg('promptId') promptId: string,
|
@Arg('promptId') promptId: string,
|
||||||
@Ctx() { sessionData }: WebsiteAPIContext,
|
@Ctx() { sessionData }: WebsiteAPIContext,
|
||||||
): Partial<PromptInfo> {
|
): Promise<Partial<PromptInfo>> {
|
||||||
const prompt = sessionData.authPrompts.get(promptId);
|
const prompt = sessionData.authPrompts.get(promptId);
|
||||||
if (!prompt) throw new UnknownPromptError();
|
if (!prompt) throw new UnknownPromptError();
|
||||||
return {
|
const client = await ClientEntity.findOne({
|
||||||
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({
|
|
||||||
where: {
|
where: {
|
||||||
clientId: prompt.clientId,
|
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 {
|
return {
|
||||||
|
id: promptId,
|
||||||
|
scopes: prompt.scopes,
|
||||||
|
studentsMode: prompt.studentsMode,
|
||||||
|
application: {
|
||||||
name: application.name,
|
name: application.name,
|
||||||
iconUrl: application.iconUrl,
|
iconUrl: application.iconUrl,
|
||||||
iconColor: application.iconColor,
|
iconColor: application.iconColor,
|
||||||
verified: application.verified,
|
verified: application.verified,
|
||||||
homepage: application.homepage,
|
homepage: application.homepage,
|
||||||
owner: await getUser(application.ownerGitHubLogin),
|
developer: await getUser(developer.gitHubLogin),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,8 +7,7 @@ import _ from 'lodash';
|
||||||
import {
|
import {
|
||||||
Arg, Ctx, Mutation, Resolver,
|
Arg, Ctx, Mutation, Resolver,
|
||||||
} from 'type-graphql';
|
} from 'type-graphql';
|
||||||
import database from '../../../../database/database';
|
import UserEntity from '../../../../database/entities/user';
|
||||||
import User from '../../../../database/entities/user';
|
|
||||||
import { decryptSymmetrical, decryptWithPrivateKey, encryptSymmetrical } from '../../../../utils';
|
import { decryptSymmetrical, decryptWithPrivateKey, encryptSymmetrical } from '../../../../utils';
|
||||||
import { InvalidSymbolError, UnknownPromptError } from '../../errors';
|
import { InvalidSymbolError, UnknownPromptError } from '../../errors';
|
||||||
import type LoginStudent from '../../models/login-student';
|
import type LoginStudent from '../../models/login-student';
|
||||||
|
@ -59,12 +58,12 @@ export default class SetSymbolResolver {
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const reportingUnits = await client.getReportingUnits();
|
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 encryptedClient = encryptSymmetrical(JSON.stringify(client.serialize()), tokenKey);
|
||||||
const encryptedDiaries = encryptSymmetrical(JSON.stringify(diaryList.map(({ serialized }) => serialized)), tokenKey);
|
const encryptedDiaries = encryptSymmetrical(JSON.stringify(diaryList.map(({ serialized }) => serialized)), tokenKey);
|
||||||
|
|
||||||
const user = await database.userRepo.findOne({
|
const user = await UserEntity.findOne({
|
||||||
where: {
|
where: {
|
||||||
host: prompt.loginInfo.host,
|
host: prompt.loginInfo.host,
|
||||||
symbol,
|
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 {
|
export default class SessionData {
|
||||||
public authPrompts = new Map<string, AuthPrompt>();
|
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 {
|
export interface TokenContent {
|
||||||
tk: string;
|
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-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
|
||||||
'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
|
'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
|
||||||
'import/prefer-default-export': ['warn'],
|
'import/prefer-default-export': ['warn'],
|
||||||
"prefer-destructuring": ["error", {
|
'prefer-destructuring': ['error', {
|
||||||
"array": false,
|
array: false,
|
||||||
"object": true
|
object: true,
|
||||||
}]
|
}],
|
||||||
},
|
},
|
||||||
overrides: [
|
overrides: [
|
||||||
{
|
{
|
||||||
files: './src/graphql/generated.ts',
|
files: './src/graphql/generated.ts',
|
||||||
rules: {
|
rules: {
|
||||||
'max-len': ['off']
|
'max-len': ['off'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
files: '**/*.vue',
|
files: '**/*.vue',
|
||||||
rules: {
|
rules: {
|
||||||
'class-methods-use-this': ['off']
|
'class-methods-use-this': ['off'],
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,9 +1,7 @@
|
||||||
schema: http://localhost:3000/api/website/graphql
|
schema: http://localhost:3000/api/website/graphql
|
||||||
documents:
|
documents:
|
||||||
- src/graphql/queries/get-prompt-info.ts
|
- src/graphql/queries/*.ts
|
||||||
- src/graphql/mutations/login.ts
|
- src/graphql/mutations/*.ts
|
||||||
- src/graphql/mutations/set-symbol.ts
|
|
||||||
- src/graphql/mutations/create-user.ts
|
|
||||||
generates:
|
generates:
|
||||||
./src/graphql/generated.ts:
|
./src/graphql/generated.ts:
|
||||||
plugins:
|
plugins:
|
||||||
|
|
56
website/package-lock.json
generated
56
website/package-lock.json
generated
|
@ -3870,12 +3870,55 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"@vue/cli-plugin-router": {
|
"@vue/cli-plugin-router": {
|
||||||
"version": "4.5.10",
|
"version": "4.5.11",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/cli-plugin-router/-/cli-plugin-router-4.5.10.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/cli-plugin-router/-/cli-plugin-router-4.5.11.tgz",
|
||||||
"integrity": "sha512-roiZTx2W59kTRaqNzHEnjnakP89MS+pVf3zWBlwsNXZpQuvqwFvoNfH/nBSJjqGRgZTRtCUe6vGgVPUEFYi/cg==",
|
"integrity": "sha512-09tzw3faOs48IUPwLutYaNC7eoyyL140fKruTwdFdXuBLDdSQVida57Brx0zj2UKXc5qF8hk4GoGrOshN0KfNg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"requires": {
|
"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": {
|
"@vue/cli-plugin-typescript": {
|
||||||
|
@ -16095,6 +16138,11 @@
|
||||||
"resolved": "https://registry.npmjs.org/vue-recaptcha/-/vue-recaptcha-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/vue-recaptcha/-/vue-recaptcha-1.3.0.tgz",
|
||||||
"integrity": "sha512-9Qf1niyHq4QbEUhsvdUkS8BoOyhYwpp8v+imUSj67ffDo9RQ6h8Ekq8EGnw/GKViXCwWalp7EEY/n/fOtU0FyA=="
|
"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": {
|
"vue-style-loader": {
|
||||||
"version": "4.1.2",
|
"version": "4.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/vue-style-loader/-/vue-style-loader-4.1.2.tgz",
|
"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-class-component": "^7.2.3",
|
||||||
"vue-property-decorator": "^9.1.2",
|
"vue-property-decorator": "^9.1.2",
|
||||||
"vue-recaptcha": "^1.3.0",
|
"vue-recaptcha": "^1.3.0",
|
||||||
|
"vue-router": "^3.2.0",
|
||||||
"vuetify": "^2.2.11"
|
"vuetify": "^2.2.11"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
@ -29,6 +30,7 @@
|
||||||
"@typescript-eslint/parser": "^2.33.0",
|
"@typescript-eslint/parser": "^2.33.0",
|
||||||
"@vue/cli-plugin-babel": "~4.5.0",
|
"@vue/cli-plugin-babel": "~4.5.0",
|
||||||
"@vue/cli-plugin-eslint": "~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-plugin-typescript": "~4.5.0",
|
||||||
"@vue/cli-service": "~4.5.0",
|
"@vue/cli-service": "~4.5.0",
|
||||||
"@vue/eslint-config-airbnb": "^5.0.2",
|
"@vue/eslint-config-airbnb": "^5.0.2",
|
||||||
|
|
|
@ -54,7 +54,7 @@
|
||||||
</v-list-item-content>
|
</v-list-item-content>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
<v-list-item
|
<v-list-item
|
||||||
:href="promptInfo.application.owner.url"
|
:href="promptInfo.application.developer.url"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener"
|
rel="noopener"
|
||||||
>
|
>
|
||||||
|
@ -65,14 +65,14 @@
|
||||||
<v-list-item-subtitle class="text-overline">
|
<v-list-item-subtitle class="text-overline">
|
||||||
Twórca
|
Twórca
|
||||||
</v-list-item-subtitle>
|
</v-list-item-subtitle>
|
||||||
<v-list-item-title v-if="promptInfo.application.owner.name">
|
<v-list-item-title v-if="promptInfo.application.developer.name">
|
||||||
{{ promptInfo.application.owner.name }}
|
{{ promptInfo.application.developer.name }}
|
||||||
<span class="text--secondary">
|
<span class="text--secondary">
|
||||||
({{ promptInfo.application.owner.login }})
|
({{ promptInfo.application.developer.login }})
|
||||||
</span>
|
</span>
|
||||||
</v-list-item-title>
|
</v-list-item-title>
|
||||||
<v-list-item-title v-else>
|
<v-list-item-title v-else>
|
||||||
{{ promptInfo.application.owner.login }}
|
{{ promptInfo.application.developer.login }}
|
||||||
</v-list-item-title>
|
</v-list-item-title>
|
||||||
</v-list-item-content>
|
</v-list-item-content>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
|
|
|
@ -50,7 +50,7 @@ import {
|
||||||
import { InputValidationRules } from 'vuetify';
|
import { InputValidationRules } from 'vuetify';
|
||||||
import IsEmail from 'isemail';
|
import IsEmail from 'isemail';
|
||||||
import { PromptInfo, VForm } from '@/types';
|
import { PromptInfo, VForm } from '@/types';
|
||||||
import { sdk } from '@/pages/authenticate-prompt/sdk';
|
import { sdk } from '@/graphql/sdk';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
name: 'EmailWindow',
|
name: 'EmailWindow',
|
||||||
|
|
|
@ -54,7 +54,7 @@ import {
|
||||||
Component, Prop, Ref, Vue,
|
Component, Prop, Ref, Vue,
|
||||||
} from 'vue-property-decorator';
|
} from 'vue-property-decorator';
|
||||||
import { PromptInfo, VForm } from '@/types';
|
import { PromptInfo, VForm } from '@/types';
|
||||||
import { hasErrorCode, sdk } from '@/pages/authenticate-prompt/sdk';
|
import { hasErrorCode, sdk } from '@/graphql/sdk';
|
||||||
import { InputValidationRules } from 'vuetify';
|
import { InputValidationRules } from 'vuetify';
|
||||||
import VueRecaptcha from 'vue-recaptcha';
|
import VueRecaptcha from 'vue-recaptcha';
|
||||||
import { requireEnv } from '@/utils';
|
import { requireEnv } from '@/utils';
|
||||||
|
|
|
@ -74,7 +74,7 @@ import {
|
||||||
Component, Prop, Ref, Vue,
|
Component, Prop, Ref, Vue,
|
||||||
} from 'vue-property-decorator';
|
} from 'vue-property-decorator';
|
||||||
import { PromptInfo, VForm } from '@/types';
|
import { PromptInfo, VForm } from '@/types';
|
||||||
import { hasErrorCode, sdk } from '@/pages/authenticate-prompt/sdk';
|
import { hasErrorCode, sdk } from '@/graphql/sdk';
|
||||||
|
|
||||||
// This is a JS Symbol, not a diary symbol
|
// This is a JS Symbol, not a diary symbol
|
||||||
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Symbol)
|
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Symbol)
|
||||||
|
|
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
|
<span class="primary--text">Wulkanowy</span> Bridge
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<v-sheet max-width="500" class="mx-auto mt-16" color="transparent">
|
||||||
<slot />
|
<slot />
|
||||||
</v-sheet>
|
</v-sheet>
|
||||||
|
|
|
@ -18,17 +18,23 @@ export type Scalars = {
|
||||||
export type Query = {
|
export type Query = {
|
||||||
__typename?: 'Query';
|
__typename?: 'Query';
|
||||||
promptInfo: PromptInfo;
|
promptInfo: PromptInfo;
|
||||||
|
applications: Array<Application>;
|
||||||
|
application: Maybe<Application>;
|
||||||
|
loginState: Maybe<LoginState>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type QueryPromptInfoArgs = {
|
export type QueryPromptInfoArgs = {
|
||||||
promptId: Scalars['String'];
|
promptId: Scalars['String'];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type QueryApplicationArgs = {
|
||||||
|
id: Scalars['String'];
|
||||||
|
};
|
||||||
|
|
||||||
export type PromptInfo = {
|
export type PromptInfo = {
|
||||||
__typename?: 'PromptInfo';
|
__typename?: 'PromptInfo';
|
||||||
id: Scalars['String'];
|
id: Scalars['String'];
|
||||||
scopes: Array<Scalars['String']>;
|
scopes: Array<Scalars['String']>;
|
||||||
clientId: Scalars['String'];
|
|
||||||
studentsMode: StudentsMode;
|
studentsMode: StudentsMode;
|
||||||
application: PromptInfoApplication;
|
application: PromptInfoApplication;
|
||||||
};
|
};
|
||||||
|
@ -45,7 +51,7 @@ export type PromptInfoApplication = {
|
||||||
iconUrl: Maybe<Scalars['String']>;
|
iconUrl: Maybe<Scalars['String']>;
|
||||||
iconColor: Scalars['String'];
|
iconColor: Scalars['String'];
|
||||||
verified: Scalars['Boolean'];
|
verified: Scalars['Boolean'];
|
||||||
owner: GitHubUser;
|
developer: GitHubUser;
|
||||||
homepage: Maybe<Scalars['String']>;
|
homepage: Maybe<Scalars['String']>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -56,11 +62,30 @@ export type GitHubUser = {
|
||||||
url: Scalars['String'];
|
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 = {
|
export type Mutation = {
|
||||||
__typename?: 'Mutation';
|
__typename?: 'Mutation';
|
||||||
createUser: CreateUserResult;
|
createUser: CreateUserResult;
|
||||||
login: LoginResult;
|
login: LoginResult;
|
||||||
setSymbol: SetSymbolResult;
|
setSymbol: SetSymbolResult;
|
||||||
|
createApplication: Application;
|
||||||
|
modifyApplication: Application;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MutationCreateUserArgs = {
|
export type MutationCreateUserArgs = {
|
||||||
|
@ -81,6 +106,16 @@ export type MutationSetSymbolArgs = {
|
||||||
promptId: Scalars['String'];
|
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 = {
|
export type CreateUserResult = {
|
||||||
__typename?: 'CreateUserResult';
|
__typename?: 'CreateUserResult';
|
||||||
success: Scalars['Boolean'];
|
success: Scalars['Boolean'];
|
||||||
|
@ -103,6 +138,18 @@ export type LoginStudent = {
|
||||||
name: Scalars['String'];
|
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<{
|
export type CreateUserMutationVariables = Exact<{
|
||||||
promptId: Scalars['String'];
|
promptId: Scalars['String'];
|
||||||
email: 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<{
|
export type SetSymbolMutationVariables = Exact<{
|
||||||
promptId: Scalars['String'];
|
promptId: Scalars['String'];
|
||||||
symbol: 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<{
|
export type GetPromptInfoQueryVariables = Exact<{
|
||||||
promptId: Scalars['String'];
|
promptId: Scalars['String'];
|
||||||
}>;
|
}>;
|
||||||
|
@ -161,7 +254,7 @@ export type GetPromptInfoQuery = (
|
||||||
& { application: (
|
& { application: (
|
||||||
{ __typename?: 'PromptInfoApplication' }
|
{ __typename?: 'PromptInfoApplication' }
|
||||||
& Pick<PromptInfoApplication, 'name' | 'iconUrl' | 'iconColor' | 'verified' | 'homepage'>
|
& Pick<PromptInfoApplication, 'name' | 'iconUrl' | 'iconColor' | 'verified' | 'homepage'>
|
||||||
& { owner: (
|
& { developer: (
|
||||||
{ __typename?: 'GitHubUser' }
|
{ __typename?: 'GitHubUser' }
|
||||||
& Pick<GitHubUser, 'login' | 'name' | 'url'>
|
& 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`
|
export const CreateUserDocument = gql`
|
||||||
mutation CreateUser($promptId: String!, $email: String!) {
|
mutation CreateUser($promptId: String!, $email: String!) {
|
||||||
createUser(promptId: $promptId, email: $email) {
|
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`
|
export const SetSymbolDocument = gql`
|
||||||
mutation SetSymbol($promptId: String!, $symbol: String!) {
|
mutation SetSymbol($promptId: String!, $symbol: String!) {
|
||||||
setSymbol(promptId: $promptId, symbol: $symbol) {
|
setSymbol(promptId: $promptId, symbol: $symbol) {
|
||||||
|
@ -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`
|
export const GetPromptInfoDocument = gql`
|
||||||
query GetPromptInfo($promptId: String!) {
|
query GetPromptInfo($promptId: String!) {
|
||||||
promptInfo(promptId: $promptId) {
|
promptInfo(promptId: $promptId) {
|
||||||
|
@ -212,7 +353,7 @@ export const GetPromptInfoDocument = gql`
|
||||||
iconColor
|
iconColor
|
||||||
verified
|
verified
|
||||||
homepage
|
homepage
|
||||||
owner {
|
developer {
|
||||||
login
|
login
|
||||||
name
|
name
|
||||||
url
|
url
|
||||||
|
@ -227,15 +368,30 @@ export type SdkFunctionWrapper = <T>(action: () => Promise<T>) => Promise<T>;
|
||||||
const defaultWrapper: SdkFunctionWrapper = (sdkFunction) => sdkFunction();
|
const defaultWrapper: SdkFunctionWrapper = (sdkFunction) => sdkFunction();
|
||||||
export function getSdk(client: GraphQLClient, withWrapper: SdkFunctionWrapper = defaultWrapper) {
|
export function getSdk(client: GraphQLClient, withWrapper: SdkFunctionWrapper = defaultWrapper) {
|
||||||
return {
|
return {
|
||||||
|
CreateApplication(variables: CreateApplicationMutationVariables, requestHeaders?: Headers): Promise<CreateApplicationMutation> {
|
||||||
|
return withWrapper(() => client.request<CreateApplicationMutation>(print(CreateApplicationDocument), variables, requestHeaders));
|
||||||
|
},
|
||||||
CreateUser(variables: CreateUserMutationVariables, requestHeaders?: Headers): Promise<CreateUserMutation> {
|
CreateUser(variables: CreateUserMutationVariables, requestHeaders?: Headers): Promise<CreateUserMutation> {
|
||||||
return withWrapper(() => client.request<CreateUserMutation>(print(CreateUserDocument), variables, requestHeaders));
|
return withWrapper(() => client.request<CreateUserMutation>(print(CreateUserDocument), variables, requestHeaders));
|
||||||
},
|
},
|
||||||
Login(variables: LoginMutationVariables, requestHeaders?: Headers): Promise<LoginMutation> {
|
Login(variables: LoginMutationVariables, requestHeaders?: Headers): Promise<LoginMutation> {
|
||||||
return withWrapper(() => client.request<LoginMutation>(print(LoginDocument), variables, requestHeaders));
|
return withWrapper(() => client.request<LoginMutation>(print(LoginDocument), variables, requestHeaders));
|
||||||
},
|
},
|
||||||
|
ModifyApplication(variables: ModifyApplicationMutationVariables, requestHeaders?: Headers): Promise<ModifyApplicationMutation> {
|
||||||
|
return withWrapper(() => client.request<ModifyApplicationMutation>(print(ModifyApplicationDocument), variables, requestHeaders));
|
||||||
|
},
|
||||||
SetSymbol(variables: SetSymbolMutationVariables, requestHeaders?: Headers): Promise<SetSymbolMutation> {
|
SetSymbol(variables: SetSymbolMutationVariables, requestHeaders?: Headers): Promise<SetSymbolMutation> {
|
||||||
return withWrapper(() => client.request<SetSymbolMutation>(print(SetSymbolDocument), variables, requestHeaders));
|
return withWrapper(() => client.request<SetSymbolMutation>(print(SetSymbolDocument), variables, requestHeaders));
|
||||||
},
|
},
|
||||||
|
GetApplication(variables: GetApplicationQueryVariables, requestHeaders?: Headers): Promise<GetApplicationQuery> {
|
||||||
|
return withWrapper(() => client.request<GetApplicationQuery>(print(GetApplicationDocument), variables, requestHeaders));
|
||||||
|
},
|
||||||
|
GetApplications(variables?: GetApplicationsQueryVariables, requestHeaders?: Headers): Promise<GetApplicationsQuery> {
|
||||||
|
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> {
|
GetPromptInfo(variables: GetPromptInfoQueryVariables, requestHeaders?: Headers): Promise<GetPromptInfoQuery> {
|
||||||
return withWrapper(() => client.request<GetPromptInfoQuery>(print(GetPromptInfoDocument), variables, requestHeaders));
|
return withWrapper(() => client.request<GetPromptInfoQuery>(print(GetPromptInfoDocument), variables, requestHeaders));
|
||||||
},
|
},
|
||||||
|
|
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
|
iconColor
|
||||||
verified
|
verified
|
||||||
homepage
|
homepage
|
||||||
owner {
|
developer {
|
||||||
login
|
login
|
||||||
name
|
name
|
||||||
url
|
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>
|
</div>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<v-card outlined>
|
<v-card outlined>
|
||||||
<div class="d-flex justify-center mn-16 avatar__wrapper">
|
<div class="d-flex justify-center app-icon-wrapper">
|
||||||
<v-badge
|
<v-badge
|
||||||
:color="promptInfo.application.verified ? 'green' : 'grey'"
|
:color="promptInfo.application.verified ? 'green' : 'grey'"
|
||||||
offset-x="64"
|
offset-x="64"
|
||||||
|
@ -26,34 +26,13 @@
|
||||||
:value="step === 'overview'"
|
:value="step === 'overview'"
|
||||||
>
|
>
|
||||||
<transition name="scale">
|
<transition name="scale">
|
||||||
<v-sheet
|
<app-icon
|
||||||
v-if="step === 'overview'"
|
v-if="step === 'overview'"
|
||||||
width="128"
|
class="mx-4"
|
||||||
height="128"
|
|
||||||
class="avatar-sheet mx-4 overflow-hidden"
|
|
||||||
outlined
|
|
||||||
>
|
|
||||||
<v-sheet
|
|
||||||
class="fill-height d-flex align-center justify-center"
|
|
||||||
:color="promptInfo.application.iconColor"
|
:color="promptInfo.application.iconColor"
|
||||||
>
|
:url="promptInfo.application.iconUrl"
|
||||||
<v-img
|
large
|
||||||
: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>
|
|
||||||
</transition>
|
</transition>
|
||||||
</v-badge>
|
</v-badge>
|
||||||
</div>
|
</div>
|
||||||
|
@ -106,15 +85,11 @@
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
.authenticate-prompt-app {
|
.authenticate-prompt-app {
|
||||||
.avatar-sheet {
|
|
||||||
border-radius: 50% !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fill-height {
|
.fill-height {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.avatar__wrapper {
|
.app-icon-wrapper {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -64px;
|
top: -64px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
@ -135,15 +110,17 @@ import OverviewWindow from '@/compontents/authenticate-prompt-windows/overview-w
|
||||||
import { PromptInfo, Student } from '@/types';
|
import { PromptInfo, Student } from '@/types';
|
||||||
import LoginWindow from '@/compontents/authenticate-prompt-windows/login-window.vue';
|
import LoginWindow from '@/compontents/authenticate-prompt-windows/login-window.vue';
|
||||||
import StudentsWindow from '@/compontents/authenticate-prompt-windows/students-window.vue';
|
import StudentsWindow from '@/compontents/authenticate-prompt-windows/students-window.vue';
|
||||||
import { sdk } from '@/pages/authenticate-prompt/sdk';
|
import { sdk } from '@/graphql/sdk';
|
||||||
import DialogApp from '@/compontents/dialog-app.vue';
|
import DialogApp from '@/compontents/dialog-app.vue';
|
||||||
import EmailWindow from '@/compontents/authenticate-prompt-windows/email-window.vue';
|
import EmailWindow from '@/compontents/authenticate-prompt-windows/email-window.vue';
|
||||||
import SymbolsWindow from '@/compontents/authenticate-prompt-windows/symbols-window.vue';
|
import SymbolsWindow from '@/compontents/authenticate-prompt-windows/symbols-window.vue';
|
||||||
import IsEmail from 'isemail';
|
import IsEmail from 'isemail';
|
||||||
|
import AppIcon from '@/pages/app-icon.vue';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
name: 'AuthenticatePromptApp',
|
name: 'AuthenticatePromptApp',
|
||||||
components: {
|
components: {
|
||||||
|
AppIcon,
|
||||||
SymbolsWindow,
|
SymbolsWindow,
|
||||||
EmailWindow,
|
EmailWindow,
|
||||||
LoginWindow,
|
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;
|
iconColor: string;
|
||||||
verified: boolean;
|
verified: boolean;
|
||||||
homepage: string | null;
|
homepage: string | null;
|
||||||
owner: {
|
developer: {
|
||||||
login: string;
|
login: string;
|
||||||
name: string | null;
|
name: string | null;
|
||||||
url: string;
|
url: string;
|
||||||
|
|
|
@ -15,5 +15,11 @@ module.exports = {
|
||||||
filename: 'prompt-error.html',
|
filename: 'prompt-error.html',
|
||||||
title: 'Błąd autoryzacji | Wulkanowy Bridge',
|
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