From 66370d8cb5be4275e11ef76641655e20140be9de Mon Sep 17 00:00:00 2001 From: Louis Capitanchik <contact@louiscap.co> Date: Tue, 29 Mar 2022 13:18:41 +0100 Subject: [PATCH] Handle storage events, handle more OIDC errors, developer portal start --- ...210000001-create-session-boot-time-view.js | 23 ++ scripts/check_img.js | 1 + scripts/populate_roots.js | 5 +- src/app.js | 11 + src/core/services/serialise.js | 1 - src/core/utils/queue.js | 4 + src/core/utils/urls.js | 8 +- src/domain/auth/AuthServer.js | 3 - src/domain/auth/AuthenticationService.js | 6 +- .../uploads/handlers/MarkUploadSuccessful.js | 27 ++ .../controllers/api/v2/classifications.js | 2 +- src/http/controllers/api/v2/uploads.js | 41 +++ src/http/controllers/dev/clients.js | 3 + src/http/controllers/oidc.js | 13 +- src/http/params/survey.js | 5 - src/http/routers/routes_v2.js | 2 + src/http/routes.js | 3 + views/developers/index.hbs | 345 ++++++++++++++++++ 18 files changed, 480 insertions(+), 23 deletions(-) create mode 100644 database/migrations/20211210000001-create-session-boot-time-view.js create mode 100644 src/domain/uploads/handlers/MarkUploadSuccessful.js create mode 100644 src/http/controllers/dev/clients.js create mode 100644 views/developers/index.hbs diff --git a/database/migrations/20211210000001-create-session-boot-time-view.js b/database/migrations/20211210000001-create-session-boot-time-view.js new file mode 100644 index 0000000..33cb452 --- /dev/null +++ b/database/migrations/20211210000001-create-session-boot-time-view.js @@ -0,0 +1,23 @@ +module.exports = { + up: (migration, Types) => { + return migration.sequelize.query(` +CREATE OR REPLACE VIEW session_boot_time as SELECT session_id, + max(start_time) - min(start_time) as boot_time, + device->>'id' as device_id, + concat_ws(' ', device -> 'info' ->> 'brand', device -> 'info' ->> 'system', + device -> 'info' ->> 'system_version') as device_info +FROM analytics +where event = 'app_open' + or event = 'navigation_ready' +group by session_id, + device->>'id', + concat_ws(' ', device -> 'info' ->> 'brand', device -> 'info' ->> 'system', + device -> 'info' ->> 'system_version') +having max(start_time) - min(start_time) > interval '0 seconds' +;`) + }, + + down: (migration, Types) => { + return migration.sequelize.query('DROP VIEW IF EXISTS session_boot_time') + }, +} \ No newline at end of file diff --git a/scripts/check_img.js b/scripts/check_img.js index b0f7420..8225d86 100644 --- a/scripts/check_img.js +++ b/scripts/check_img.js @@ -23,6 +23,7 @@ async function main() { try { await fs.makePublic(path) } catch(e) { + console.error(e) console.error('Couldnt make ', path, ' public') upload.status = 'failed' upload.status_reason = 'bogus upload url' diff --git a/scripts/populate_roots.js b/scripts/populate_roots.js index 4a39d21..58babb8 100644 --- a/scripts/populate_roots.js +++ b/scripts/populate_roots.js @@ -13,12 +13,13 @@ where from classification_roots where classification_roots.metric_id = metrics.id ) -limit 20;` +limit 100` const InsertQuery = `insert into classification_roots(metric_id, upload_id, image_id, url) ${ SelectQuery } -` + ON CONFLICT DO NOTHING +;` async function main() { const { sequelize } = require('database/models') diff --git a/src/app.js b/src/app.js index 98f4a76..7304126 100644 --- a/src/app.js +++ b/src/app.js @@ -36,6 +36,17 @@ module.exports = async function createApp(app = new Koa()) { app.use(logger(s => requestLog(s))) app.use(static(pathutil.resolve(__dirname + '/../public'))) + app.use(async (ctx, next) => { + if (ctx.method === 'OPTIONS') { + ctx.response.set('Access-Control-Allow-Origin', ctx.request.origin) + ctx.response.set('Access-Control-Allow-Method', ctx.request.get('Access-Control-Request-Method')) + ctx.response.set('Access-Control-Allow-Headers', ctx.request.get('Access-Control-Request-Headers')) + ctx.response.status = 201 + } else { + return await next() + } + }) + app.use( session( { diff --git a/src/core/services/serialise.js b/src/core/services/serialise.js index 3e80e4d..172ecbe 100644 --- a/src/core/services/serialise.js +++ b/src/core/services/serialise.js @@ -6,7 +6,6 @@ exports.serialise = async function serialise(pattern, data) { case 'string': case 'number': case 'symbol': - console.log(data[mapper]) output[key] = data[mapper] break case 'function': diff --git a/src/core/utils/queue.js b/src/core/utils/queue.js index c96201a..2615461 100644 --- a/src/core/utils/queue.js +++ b/src/core/utils/queue.js @@ -3,6 +3,10 @@ const HANDLERS = [ 'send-user-password-reset', require('domain/auth/handlers/SendUserPasswordReset'), ], + [ + 'upload-successful', + require('domain/uploads/handlers/MarkUploadSuccessful'), + ], ] module.exports = function bindJobHandlers() { diff --git a/src/core/utils/urls.js b/src/core/utils/urls.js index 2f04912..70f6934 100644 --- a/src/core/utils/urls.js +++ b/src/core/utils/urls.js @@ -30,7 +30,7 @@ const urlToName = redirectPairs.reduce((cur, [k, v]) => ({ ...cur, [v]: k }), {} exports.createRedirectState = async ctx => { const path = ctx.path - const redirect = urlToName[path] ?? '/' + const redirect = urlToName[path] ?? `$$${ctx.path}` const query = ctx.request.query ?? {} return crypto.encrypt(JSON.stringify({ redirect, query })) } @@ -50,7 +50,9 @@ exports.parseRedirectState = async ctx => { const value = JSON.parse(raw) return { ...value, - path: nameToUrl[value.redirect] ?? '/', + path: value.redirect.startsWith('$$') ? + value.redirect.substr(2) : + nameToUrl[value.redirect] ?? '/', } } catch(e) { console.error(e) @@ -68,8 +70,6 @@ exports.createRedirectedUrl = async (ctx) => { const values = await exports.parseRedirectState(ctx) const params = new URLSearchParams() - console.log(values, params) - Object.entries(values.query).forEach(([key, value]) => { params.set(key, value) }) diff --git a/src/domain/auth/AuthServer.js b/src/domain/auth/AuthServer.js index fed5552..728af4e 100644 --- a/src/domain/auth/AuthServer.js +++ b/src/domain/auth/AuthServer.js @@ -223,9 +223,6 @@ class KoaOAuthServer { this.token = async ctx => { const { req, res } = this.transformContext(ctx) - console.log(ctx.request.query) - console.log(ctx.request.body) - await authServer.token(req, res, { allowExtendedTokenAttributes: true, accessTokenLifetime: 3600 * 24 * 7, diff --git a/src/domain/auth/AuthenticationService.js b/src/domain/auth/AuthenticationService.js index 78a5b5c..9da4baa 100644 --- a/src/domain/auth/AuthenticationService.js +++ b/src/domain/auth/AuthenticationService.js @@ -58,11 +58,15 @@ module.exports = class AuthenticationService extends ContextualModule { } else if (this.ctx.get('Authorization')) { const token = this.ctx.get('Authorization').substr(HEADER_PREFIX.length) - const accessToken = await AccessToken.findOne({ + let accessToken = await AccessToken.findOne({ where: { token }, include: [{ model: User }], }) + if (accessToken == null) { + accessToken =await this.ctx.services['auth.oidc'].withProvider(provider => provider.AccessToken.find(token)) + } + if (accessToken.User) { this.authenticateAs(accessToken.User) return this._user diff --git a/src/domain/uploads/handlers/MarkUploadSuccessful.js b/src/domain/uploads/handlers/MarkUploadSuccessful.js new file mode 100644 index 0000000..f10a57e --- /dev/null +++ b/src/domain/uploads/handlers/MarkUploadSuccessful.js @@ -0,0 +1,27 @@ + +module.exports = async (body, ctx) => { + const {bucket, key} = body + const stub = `${bucket}/${key}` + + const {Upload, Sequelize} = require('database/models') + const upload = await Upload.findOne({ + where: { + upload_url: { + [Sequelize.Op.like]: `%${stub}%`, + } + } + }) + + if (upload) { + upload.status = 'success' + + try { + const { fs } = require('services') + await fs.makePublic(key) + } catch (e) { + upload.status_reason = 'Failed to make public' + } + + await upload.save() + } +} \ No newline at end of file diff --git a/src/http/controllers/api/v2/classifications.js b/src/http/controllers/api/v2/classifications.js index 00f5d55..163ecec 100644 --- a/src/http/controllers/api/v2/classifications.js +++ b/src/http/controllers/api/v2/classifications.js @@ -14,7 +14,7 @@ exports.listRoots = async ctx => { .filter(s => publicStatus.has(s) || privateStatus.has(s)) const query = { - order: ['metric_id', 'created_at'], + order: ['metric_id', ['created_at', 'DESC']], where: { status: { [Sequelize.Op.in]: requestedStatus, diff --git a/src/http/controllers/api/v2/uploads.js b/src/http/controllers/api/v2/uploads.js index 62f0358..af11876 100644 --- a/src/http/controllers/api/v2/uploads.js +++ b/src/http/controllers/api/v2/uploads.js @@ -1,3 +1,4 @@ +const {queue} = require("services"); exports.createUpload = async ctx => { const { fs } = require('services') const { v4: uuid } = require('uuid') @@ -28,3 +29,43 @@ exports.createUpload = async ctx => { ctx.body = { upload: payload } } + +/** + * { + * message: { + * attributes: { + * bucketId: 'jetsam-uploads-staging', + * eventTime: '2022-02-23T18:10:22.885464Z', + * eventType: 'OBJECT_FINALIZE', + * notificationConfig: 'projects/_/buckets/jetsam-uploads-staging/notificationConfigs/3', + * objectGeneration: '1645639822790386', + * objectId: 'funi.png', + * overwroteGeneration: '1645638845013021', + * payloadFormat: 'JSON_API_V1' + * }, + * data: 'ewogICJraW5kIjogInN0b3JhZ2Ujb2JqZWN0IiwKICAiaWQiOiAiamV0c2FtLXVwbG9hZHMtc3RhZ2luZy9mdW5pLnBuZy8xNjQ1NjM5ODIyNzkwMzg2IiwKICAic2VsZkxpbmsiOiAiaHR0cHM6Ly93d3cuZ29vZ2xlYXBpcy5jb20vc3RvcmFnZS92MS9iL2pldHNhbS11cGxvYWRzLXN0YWdpbmcvby9mdW5pLnBuZyIsCiAgIm5hbWUiOiAiZnVuaS5wbmciLAogICJidWNrZXQiOiAiamV0c2FtLXVwbG9hZHMtc3RhZ2luZyIsCiAgImdlbmVyYXRpb24iOiAiMTY0NTYzOTgyMjc5MDM4NiIsCiAgIm1ldGFnZW5lcmF0aW9uIjogIjEiLAogICJjb250ZW50VHlwZSI6ICJpbWFnZS9wbmciLAogICJ0aW1lQ3JlYXRlZCI6ICIyMDIyLTAyLTIzVDE4OjEwOjIyLjg4NVoiLAogICJ1cGRhdGVkIjogIjIwMjItMDItMjNUMTg6MTA6MjIuODg1WiIsCiAgInN0b3JhZ2VDbGFzcyI6ICJTVEFOREFSRCIsCiAgInRpbWVTdG9yYWdlQ2xhc3NVcGRhdGVkIjogIjIwMjItMDItMjNUMTg6MTA6MjIuODg1WiIsCiAgInNpemUiOiAiMTI2MDkiLAogICJtZDVIYXNoIjogIlZhakdKT3Q0QlV4OWxubVF4YURQaUE9PSIsCiAgIm1lZGlhTGluayI6ICJodHRwczovL3d3dy5nb29nbGVhcGlzLmNvbS9kb3dubG9hZC9zdG9yYWdlL3YxL2IvamV0c2FtLXVwbG9hZHMtc3RhZ2luZy9vL2Z1bmkucG5nP2dlbmVyYXRpb249MTY0NTYzOTgyMjc5MDM4NiZhbHQ9bWVkaWEiLAogICJjcmMzMmMiOiAiZUZ1L0VBPT0iLAogICJldGFnIjogIkNQS2R3NGkybHZZQ0VBRT0iCn0K', + * messageId: '4057500370063368', + * message_id: '4057500370063368', + * publishTime: '2022-02-23T18:10:23.229Z', + * publish_time: '2022-02-23T18:10:23.229Z' + * }, + * subscription: 'projects/hot-trash-server/subscriptions/jetsam-uploads-staging-events-subscription' + * } + */ + +exports.storageEventsWebhook = async ctx => { + const { queue } = require('services') + + const { message } = ctx.request.body + const bucket = message?.attributes?.bucketId + const key = message?.attributes?.objectId + + if (message?.attributes?.eventType === 'OBJECT_FINALIZE') { + await queue.dispatch('upload-successful', { + bucket, + key, + }) + } + + ctx.status = 204 +} \ No newline at end of file diff --git a/src/http/controllers/dev/clients.js b/src/http/controllers/dev/clients.js new file mode 100644 index 0000000..f0e8611 --- /dev/null +++ b/src/http/controllers/dev/clients.js @@ -0,0 +1,3 @@ +exports.showClients = async ctx => { + return await ctx.render('developers/index') +} \ No newline at end of file diff --git a/src/http/controllers/oidc.js b/src/http/controllers/oidc.js index d8d6a97..4e9acfb 100644 --- a/src/http/controllers/oidc.js +++ b/src/http/controllers/oidc.js @@ -20,12 +20,13 @@ exports.handleLogin = async ctx => { } const { email, password } = ctx.request.body - const user = await ctx.services['core.auth'].attemptLogin(email, password) - - const result = { - login: { - accountId: user.id, - }, + const result = {} + try { + const user = await ctx.services['core.auth'].attemptLogin(email, password) + result.login = { accountId: user.id } + } catch(e) { + result.error = 'invalid_credentials' + result.error_description = e.message } return await ctx.services['auth.oidc'].withProvider(p => p.interactionFinished(ctx.req, ctx.res, result, { diff --git a/src/http/params/survey.js b/src/http/params/survey.js index 92ded5d..70af8a6 100644 --- a/src/http/params/survey.js +++ b/src/http/params/survey.js @@ -14,9 +14,6 @@ module.exports = async (id, ctx, next) => { } }] : [] - console.log("INCLUDES", includes) - - ctx.models.survey = await Survey.findOne({ where: { id, @@ -31,7 +28,5 @@ module.exports = async (id, ctx, next) => { include: includes }) - console.log(ctx.models.survey) - return await next() } diff --git a/src/http/routers/routes_v2.js b/src/http/routers/routes_v2.js index 3a1e040..7c4683d 100644 --- a/src/http/routers/routes_v2.js +++ b/src/http/routers/routes_v2.js @@ -69,6 +69,8 @@ if (config('app.dev')) { router.get('/classifications/roots', controller('api/v2/classifications', 'listRoots')) router.put('/classifications/roots/:classification/status', controller('api/v2/classifications', 'putRootStatus')) +router.post('/webhooks/storage/events', controller('api/v2/uploads', 'storageEventsWebhook')) + router.post('/an/ev', safemode, controller('api/analytics', 'track')) module.exports = router diff --git a/src/http/routes.js b/src/http/routes.js index c948e3b..f206ce0 100644 --- a/src/http/routes.js +++ b/src/http/routes.js @@ -63,6 +63,9 @@ web.post('/reset-password', controller('auth', 'handleResetPassword')) web.get('/auth/authorize', authRedirect, AuthServer.authorize) web.post('/auth/authorize', AuthServer.authorize) web.post('/auth/token', AuthServer.token) + +web.get('/developers', authRedirect, controller('dev/clients', 'showClients')) + env('FS_DRIVER', 'local') === 'local' && (function () { const debug = require('debug')('server:routes') diff --git a/views/developers/index.hbs b/views/developers/index.hbs new file mode 100644 index 0000000..6a00a70 --- /dev/null +++ b/views/developers/index.hbs @@ -0,0 +1,345 @@ +<!DOCTYPE html> + +<html class="h-full bg-gray-100" lang="en"> +<head> + <title>Login | Jetsam</title> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> + <meta name="description" content=""> + <meta name="author" content=""> + <!-- <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/normalize/8.0.1/normalize.min.css">--> + <script src="https://cdn.tailwindcss.com"></script> + + <style> + body, body > div { + height: 100%; + } + </style> +</head> +<body class="h-full"> +<aside class="lg:grid lg:grid-cols-12 lg:gap-x-5"> + <div class="flex-1 flex flex-col min-h-0 bg-indigo-700"> + <div class="flex-1 flex flex-col pt-5 pb-4 overflow-y-auto"> + <div class="flex items-center flex-shrink-0 px-4"> + <img class="h-8 w-auto" src="https://tailwindui.com/img/logos/workflow-logo-indigo-300-mark-white-text.svg" alt="Workflow"> + </div> + <nav class="mt-5 flex-1 px-2 space-y-1"> + <!-- Current: "bg-indigo-800 text-white", Default: "text-white hover:bg-indigo-600 hover:bg-opacity-75" --> + <a href="#" class="bg-indigo-800 text-white group flex items-center px-2 py-2 text-sm font-medium rounded-md"> + <!-- Heroicon name: outline/home --> + <svg class="mr-3 flex-shrink-0 h-6 w-6 text-indigo-300" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" /> + </svg> + Dashboard + </a> + + <a href="#" class="text-white hover:bg-indigo-600 hover:bg-opacity-75 group flex items-center px-2 py-2 text-sm font-medium rounded-md"> + <!-- Heroicon name: outline/users --> + <svg class="mr-3 flex-shrink-0 h-6 w-6 text-indigo-300" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" /> + </svg> + Team + </a> + + <a href="#" class="text-white hover:bg-indigo-600 hover:bg-opacity-75 group flex items-center px-2 py-2 text-sm font-medium rounded-md"> + <!-- Heroicon name: outline/folder --> + <svg class="mr-3 flex-shrink-0 h-6 w-6 text-indigo-300" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" /> + </svg> + Projects + </a> + + <a href="#" class="text-white hover:bg-indigo-600 hover:bg-opacity-75 group flex items-center px-2 py-2 text-sm font-medium rounded-md"> + <!-- Heroicon name: outline/calendar --> + <svg class="mr-3 flex-shrink-0 h-6 w-6 text-indigo-300" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" /> + </svg> + Calendar + </a> + + <a href="#" class="text-white hover:bg-indigo-600 hover:bg-opacity-75 group flex items-center px-2 py-2 text-sm font-medium rounded-md"> + <!-- Heroicon name: outline/inbox --> + <svg class="mr-3 flex-shrink-0 h-6 w-6 text-indigo-300" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" /> + </svg> + Documents + </a> + + <a href="#" class="text-white hover:bg-indigo-600 hover:bg-opacity-75 group flex items-center px-2 py-2 text-sm font-medium rounded-md"> + <!-- Heroicon name: outline/chart-bar --> + <svg class="mr-3 flex-shrink-0 h-6 w-6 text-indigo-300" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" /> + </svg> + Reports + </a> + </nav> + </div> + <div class="flex-shrink-0 flex border-t border-indigo-800 p-4"> + <a href="#" class="flex-shrink-0 w-full group block"> + <div class="flex items-center"> + <div> + <img class="inline-block h-9 w-9 rounded-full" src="https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80" alt=""> + </div> + <div class="ml-3"> + <p class="text-sm font-medium text-white">Tom Cook</p> + <p class="text-xs font-medium text-indigo-200 group-hover:text-white">View profile</p> + </div> + </div> + </a> + </div> + </div> +</aside> +<!--<div class="md:pl-64 flex flex-col flex-1">--> +<!-- <div class="sticky top-0 z-10 md:hidden pl-1 pt-1 sm:pl-3 sm:pt-3 bg-gray-100">--> +<!-- <button type="button" class="-ml-0.5 -mt-0.5 h-12 w-12 inline-flex items-center justify-center rounded-md text-gray-500 hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-indigo-500">--> +<!-- <span class="sr-only">Open sidebar</span>--> +<!-- <!– Heroicon name: outline/menu –>--> +<!-- <svg class="h-6 w-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">--> +<!-- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />--> +<!-- </svg>--> +<!-- </button>--> +<!-- </div>--> + + <div class="flex-1 space-y-6 sm:px-6 lg:px-0 lg:col-span-9"> + <form action="#" method="POST"> + <div class="shadow sm:rounded-md sm:overflow-hidden"> + <div class="bg-white py-6 px-4 space-y-6 sm:p-6"> + <div> + <h3 class="text-lg leading-6 font-medium text-gray-900">Profile</h3> + <p class="mt-1 text-sm text-gray-500">This information will be displayed publicly so be careful + what you share.</p> + </div> + + <div class="grid grid-cols-3 gap-6"> + <div class="col-span-3 sm:col-span-2"> + <label for="company-website" class="block text-sm font-medium text-gray-700"> + Username </label> + <div class="mt-1 rounded-md shadow-sm flex"> + <span class="bg-gray-50 border border-r-0 border-gray-300 rounded-l-md px-3 inline-flex items-center text-gray-500 sm:text-sm"> workcation.com/ </span> + <input type="text" name="username" id="username" autocomplete="username" + class="focus:ring-indigo-500 focus:border-indigo-500 flex-grow block w-full min-w-0 rounded-none rounded-r-md sm:text-sm border-gray-300"> + </div> + </div> + + <div class="col-span-3"> + <label for="about" class="block text-sm font-medium text-gray-700"> About </label> + <div class="mt-1"> + <textarea id="about" name="about" rows="3" + class="shadow-sm focus:ring-indigo-500 focus:border-indigo-500 mt-1 block w-full sm:text-sm border border-gray-300 rounded-md" + placeholder="you@example.com"></textarea> + </div> + <p class="mt-2 text-sm text-gray-500">Brief description for your profile. URLs are + hyperlinked.</p> + </div> + + <div class="col-span-3"> + <label class="block text-sm font-medium text-gray-700"> Photo </label> + <div class="mt-1 flex items-center"> + <span class="inline-block bg-gray-100 rounded-full overflow-hidden h-12 w-12"> + <svg class="h-full w-full text-gray-300" fill="currentColor" viewBox="0 0 24 24"> + <path d="M24 20.993V24H0v-2.996A14.977 14.977 0 0112.004 15c4.904 0 9.26 2.354 11.996 5.993zM16.002 8.999a4 4 0 11-8 0 4 4 0 018 0z"/> + </svg> + </span> + <button type="button" + class="ml-5 bg-white border border-gray-300 rounded-md shadow-sm py-2 px-3 text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"> + Change + </button> + </div> + </div> + + <div class="col-span-3"> + <label class="block text-sm font-medium text-gray-700"> Cover photo </label> + <div class="mt-1 border-2 border-gray-300 border-dashed rounded-md px-6 pt-5 pb-6 flex justify-center"> + <div class="space-y-1 text-center"> + <svg class="mx-auto h-12 w-12 text-gray-400" stroke="currentColor" fill="none" + viewBox="0 0 48 48" aria-hidden="true"> + <path d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02" + stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> + </svg> + <div class="flex text-sm text-gray-600"> + <label for="file-upload" + class="relative cursor-pointer bg-white rounded-md font-medium text-indigo-600 hover:text-indigo-500 focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2 focus-within:ring-indigo-500"> + <span>Upload a file</span> + <input id="file-upload" name="file-upload" type="file" class="sr-only"> + </label> + <p class="pl-1">or drag and drop</p> + </div> + <p class="text-xs text-gray-500">PNG, JPG, GIF up to 10MB</p> + </div> + </div> + </div> + </div> + </div> + <div class="px-4 py-3 bg-gray-50 text-right sm:px-6"> + <button type="submit" + class="bg-indigo-600 border border-transparent rounded-md shadow-sm py-2 px-4 inline-flex justify-center text-sm font-medium text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"> + Save + </button> + </div> + </div> + </form> + + <form action="#" method="POST"> + <div class="shadow sm:rounded-md sm:overflow-hidden"> + <div class="bg-white py-6 px-4 space-y-6 sm:p-6"> + <div> + <h3 class="text-lg leading-6 font-medium text-gray-900">Personal Information</h3> + <p class="mt-1 text-sm text-gray-500">Use a permanent address where you can recieve mail.</p> + </div> + + <div class="grid grid-cols-6 gap-6"> + <div class="col-span-6 sm:col-span-3"> + <label for="first-name" class="block text-sm font-medium text-gray-700">First name</label> + <input type="text" name="first-name" id="first-name" autocomplete="given-name" + class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"> + </div> + + <div class="col-span-6 sm:col-span-3"> + <label for="last-name" class="block text-sm font-medium text-gray-700">Last name</label> + <input type="text" name="last-name" id="last-name" autocomplete="family-name" + class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"> + </div> + + <div class="col-span-6 sm:col-span-4"> + <label for="email-address" class="block text-sm font-medium text-gray-700">Email + address</label> + <input type="text" name="email-address" id="email-address" autocomplete="email" + class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"> + </div> + + <div class="col-span-6 sm:col-span-3"> + <label for="country" class="block text-sm font-medium text-gray-700">Country</label> + <select id="country" name="country" autocomplete="country-name" + class="mt-1 block w-full bg-white border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"> + <option>United States</option> + <option>Canada</option> + <option>Mexico</option> + </select> + </div> + + <div class="col-span-6"> + <label for="street-address" class="block text-sm font-medium text-gray-700">Street + address</label> + <input type="text" name="street-address" id="street-address" autocomplete="street-address" + class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"> + </div> + + <div class="col-span-6 sm:col-span-6 lg:col-span-2"> + <label for="city" class="block text-sm font-medium text-gray-700">City</label> + <input type="text" name="city" id="city" autocomplete="address-level2" + class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"> + </div> + + <div class="col-span-6 sm:col-span-3 lg:col-span-2"> + <label for="region" class="block text-sm font-medium text-gray-700">State / Province</label> + <input type="text" name="region" id="region" autocomplete="address-level1" + class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"> + </div> + + <div class="col-span-6 sm:col-span-3 lg:col-span-2"> + <label for="postal-code" class="block text-sm font-medium text-gray-700">ZIP / Postal + code</label> + <input type="text" name="postal-code" id="postal-code" autocomplete="postal-code" + class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"> + </div> + </div> + </div> + <div class="px-4 py-3 bg-gray-50 text-right sm:px-6"> + <button type="submit" + class="bg-indigo-600 border border-transparent rounded-md shadow-sm py-2 px-4 inline-flex justify-center text-sm font-medium text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"> + Save + </button> + </div> + </div> + </form> + + <form action="#" method="POST"> + <div class="shadow sm:rounded-md sm:overflow-hidden"> + <div class="bg-white py-6 px-4 space-y-6 sm:p-6"> + <div> + <h3 class="text-lg leading-6 font-medium text-gray-900">Notifications</h3> + <p class="mt-1 text-sm text-gray-500">Provide basic informtion about the job. Be specific with + the job title.</p> + </div> + + <fieldset> + <legend class="text-base font-medium text-gray-900">By Email</legend> + <div class="mt-4 space-y-4"> + <div class="flex items-start"> + <div class="h-5 flex items-center"> + <input id="comments" name="comments" type="checkbox" + class="focus:ring-indigo-500 h-4 w-4 text-indigo-600 border-gray-300 rounded"> + </div> + <div class="ml-3 text-sm"> + <label for="comments" class="font-medium text-gray-700">Comments</label> + <p class="text-gray-500">Get notified when someones posts a comment on a + posting.</p> + </div> + </div> + <div> + <div class="flex items-start"> + <div class="h-5 flex items-center"> + <input id="candidates" name="candidates" type="checkbox" + class="focus:ring-indigo-500 h-4 w-4 text-indigo-600 border-gray-300 rounded"> + </div> + <div class="ml-3 text-sm"> + <label for="candidates" class="font-medium text-gray-700">Candidates</label> + <p class="text-gray-500">Get notified when a candidate applies for a job.</p> + </div> + </div> + </div> + <div> + <div class="flex items-start"> + <div class="h-5 flex items-center"> + <input id="offers" name="offers" type="checkbox" + class="focus:ring-indigo-500 h-4 w-4 text-indigo-600 border-gray-300 rounded"> + </div> + <div class="ml-3 text-sm"> + <label for="offers" class="font-medium text-gray-700">Offers</label> + <p class="text-gray-500">Get notified when a candidate accepts or rejects an + offer.</p> + </div> + </div> + </div> + </div> + </fieldset> + <fieldset class="mt-6"> + <legend class="text-base font-medium text-gray-900">Push Notifications</legend> + <p class="text-sm text-gray-500">These are delivered via SMS to your mobile phone.</p> + <div class="mt-4 space-y-4"> + <div class="flex items-center"> + <input id="push-everything" name="push-notifications" type="radio" + class="focus:ring-indigo-500 h-4 w-4 text-indigo-600 border-gray-300"> + <label for="push-everything" class="ml-3"> + <span class="block text-sm font-medium text-gray-700">Everything</span> + </label> + </div> + <div class="flex items-center"> + <input id="push-email" name="push-notifications" type="radio" + class="focus:ring-indigo-500 h-4 w-4 text-indigo-600 border-gray-300"> + <label for="push-email" class="ml-3"> + <span class="block text-sm font-medium text-gray-700">Same as email</span> + </label> + </div> + <div class="flex items-center"> + <input id="push-nothing" name="push-notifications" type="radio" + class="focus:ring-indigo-500 h-4 w-4 text-indigo-600 border-gray-300"> + <label for="push-nothing" class="ml-3"> + <span class="block text-sm font-medium text-gray-700">No push notifications</span> + </label> + </div> + </div> + </fieldset> + </div> + <div class="px-4 py-3 bg-gray-50 text-right sm:px-6"> + <button type="submit" + class="bg-indigo-600 border border-transparent rounded-md shadow-sm py-2 px-4 inline-flex justify-center text-sm font-medium text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-600"> + Save + </button> + </div> + </div> + </form> + </div> +</div> +</body> +</html> -- GitLab