mirror of https://github.com/nocodb/nocodb
Pranav C
2 years ago
39 changed files with 144 additions and 5158 deletions
@ -1,93 +0,0 @@
|
||||
export default ({ |
||||
ncSiteUrl, |
||||
}: { |
||||
ncSiteUrl: string; |
||||
}): string => `<!DOCTYPE html>
|
||||
<html> |
||||
<head> |
||||
<title>NocoDB API Documentation</title> |
||||
<!-- needed for adaptive design --> |
||||
<meta charset="utf-8"/> |
||||
<meta name="viewport" content="width=device-width, initial-scale=1"> |
||||
<link href="${ncSiteUrl}/css/fonts.montserrat.css" rel="stylesheet"> |
||||
<!-- |
||||
Redoc doesn't change outer page styles |
||||
--> |
||||
<style> |
||||
body { |
||||
margin: 0; |
||||
padding: 0; |
||||
} |
||||
</style> |
||||
</head> |
||||
<body> |
||||
<div id="redoc"></div> |
||||
<script src="${ncSiteUrl}/js/redoc.standalone.min.js"></script> |
||||
<script> |
||||
let initialLocalStorage = {} |
||||
|
||||
try { |
||||
initialLocalStorage = JSON.parse(localStorage.getItem('nocodb-gui-v2') || '{}'); |
||||
} catch (e) { |
||||
console.error('Failed to parse local storage', e); |
||||
} |
||||
|
||||
const xhttp = new XMLHttpRequest(); |
||||
|
||||
xhttp.open("GET", "./swagger.json"); |
||||
xhttp.setRequestHeader("Content-Type", "application/json;charset=UTF-8"); |
||||
xhttp.setRequestHeader("xc-auth", initialLocalStorage && initialLocalStorage.token); |
||||
|
||||
xhttp.onload = function () { |
||||
const swaggerJson = this.responseText; |
||||
const swagger = JSON.parse(swaggerJson); |
||||
Redoc.init(swagger, { |
||||
scrollYOffset: 50 |
||||
}, document.getElementById('redoc')) |
||||
}; |
||||
|
||||
xhttp.send(); |
||||
</script> |
||||
<script> |
||||
console.log('%c🚀 We are Hiring!!! 🚀%c\\n%cJoin the forces http://careers.nocodb.com', 'color:#1348ba;font-size:3rem;padding:20px;', 'display:none', 'font-size:1.5rem;padding:20px') |
||||
const linkEl = document.createElement('a') |
||||
linkEl.setAttribute('href', "http://careers.nocodb.com") |
||||
linkEl.setAttribute('target', '_blank') |
||||
linkEl.setAttribute('class', 'we-are-hiring') |
||||
linkEl.innerHTML = '🚀 We are Hiring!!! 🚀' |
||||
const styleEl = document.createElement('style'); |
||||
styleEl.innerHTML = \` |
||||
.we-are-hiring { |
||||
position: fixed; |
||||
bottom: 50px; |
||||
right: -250px; |
||||
opacity: 0; |
||||
background: orange; |
||||
border-radius: 4px; |
||||
padding: 19px; |
||||
z-index: 200; |
||||
text-decoration: none;
|
||||
text-transform: uppercase; |
||||
color: black; |
||||
transition: 1s opacity, 1s right; |
||||
display: block; |
||||
font-weight: bold; |
||||
}
|
||||
|
||||
.we-are-hiring.active { |
||||
opacity: 1; |
||||
right:25px; |
||||
} |
||||
|
||||
@media only screen and (max-width: 600px) { |
||||
.we-are-hiring { |
||||
display: none; |
||||
} |
||||
} |
||||
\` |
||||
document.body.appendChild(linkEl, document.body.firstChild) |
||||
document.body.appendChild(styleEl, document.body.firstChild) |
||||
setTimeout(() => linkEl.classList.add('active'), 2000) |
||||
</script> |
||||
</body> |
||||
</html>`;
|
@ -1,61 +0,0 @@
|
||||
// @ts-ignore
|
||||
import catchError, { NcError } from '../../meta/helpers/catchError'; |
||||
import { Router } from 'express'; |
||||
import Model from '../../models/Model'; |
||||
import ncMetaAclMw from '../../meta/helpers/ncMetaAclMw'; |
||||
import getSwaggerJSON from './helpers/getSwaggerJSON'; |
||||
import Project from '../../models/Project'; |
||||
import getSwaggerHtml from './swaggerHtml'; |
||||
import getRedocHtml from './redocHtml'; |
||||
|
||||
async function swaggerJson(req, res) { |
||||
const project = await Project.get(req.params.projectId); |
||||
|
||||
if (!project) NcError.notFound(); |
||||
|
||||
const models = await Model.list({ |
||||
project_id: req.params.projectId, |
||||
base_id: null, |
||||
}); |
||||
|
||||
const swagger = await getSwaggerJSON(project, models); |
||||
|
||||
swagger.servers = [ |
||||
{ |
||||
url: req.ncSiteUrl, |
||||
}, |
||||
{ |
||||
url: '{customUrl}', |
||||
variables: { |
||||
customUrl: { |
||||
default: req.ncSiteUrl, |
||||
description: 'Provide custom nocodb app base url', |
||||
}, |
||||
}, |
||||
}, |
||||
] as any; |
||||
|
||||
res.json(swagger); |
||||
} |
||||
|
||||
function swaggerHtml(_, res) { |
||||
res.send(getSwaggerHtml({ ncSiteUrl: process.env.NC_PUBLIC_URL || '' })); |
||||
} |
||||
|
||||
function redocHtml(_, res) { |
||||
res.send(getRedocHtml({ ncSiteUrl: process.env.NC_PUBLIC_URL || '' })); |
||||
} |
||||
|
||||
const router = Router({ mergeParams: true }); |
||||
|
||||
// todo: auth
|
||||
router.get( |
||||
'/api/v1/db/meta/projects/:projectId/swagger.json', |
||||
ncMetaAclMw(swaggerJson, 'swaggerJson') |
||||
); |
||||
|
||||
router.get('/api/v1/db/meta/projects/:projectId/swagger', swaggerHtml); |
||||
|
||||
router.get('/api/v1/db/meta/projects/:projectId/redoc', redocHtml); |
||||
|
||||
export default router; |
@ -1,87 +0,0 @@
|
||||
export default ({ |
||||
ncSiteUrl, |
||||
}: { |
||||
ncSiteUrl: string; |
||||
}): string => `<!DOCTYPE html>
|
||||
<html> |
||||
<head> |
||||
<title>NocoDB : API Docs</title> |
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, minimal-ui"> |
||||
<link rel="shortcut icon" href="${ncSiteUrl}/favicon.ico" /> |
||||
<link rel="stylesheet" href="${ncSiteUrl}/css/swagger-ui-bundle.4.5.2.min.css"/> |
||||
<script src="${ncSiteUrl}/js/swagger-ui-bundle.4.5.2.min.js"></script> |
||||
</head> |
||||
<body> |
||||
<div id="app"></div> |
||||
<script> |
||||
|
||||
let initialLocalStorage = {} |
||||
|
||||
try { |
||||
initialLocalStorage = JSON.parse(localStorage.getItem('nocodb-gui-v2') || '{}'); |
||||
} catch (e) { |
||||
console.error('Failed to parse local storage', e); |
||||
} |
||||
|
||||
var xmlhttp = new XMLHttpRequest(); // new HttpRequest instance
|
||||
xmlhttp.open("GET", "./swagger.json"); |
||||
xmlhttp.setRequestHeader("Content-Type", "application/json;charset=UTF-8"); |
||||
xmlhttp.setRequestHeader("xc-auth", initialLocalStorage && initialLocalStorage.token); |
||||
xmlhttp.onload = function () { |
||||
|
||||
const ui = SwaggerUIBundle({ |
||||
// url: ,
|
||||
spec: JSON.parse(xmlhttp.responseText), |
||||
dom_id: '#app', |
||||
presets: [ |
||||
SwaggerUIBundle.presets.apis, |
||||
SwaggerUIBundle.SwaggerUIStandalonePreset |
||||
], |
||||
}) |
||||
} |
||||
xmlhttp.send(); |
||||
|
||||
|
||||
console.log('%c🚀 We are Hiring!!! 🚀%c\\n%cJoin the forces http://careers.nocodb.com', 'color:#1348ba;font-size:3rem;padding:20px;', 'display:none', 'font-size:1.5rem;padding:20px'); |
||||
const linkEl = document.createElement('a') |
||||
linkEl.setAttribute('href', "http://careers.nocodb.com") |
||||
linkEl.setAttribute('target', '_blank') |
||||
linkEl.setAttribute('class', 'we-are-hiring') |
||||
linkEl.innerHTML = '🚀 We are Hiring!!! 🚀' |
||||
const styleEl = document.createElement('style'); |
||||
styleEl.innerHTML = \` |
||||
.we-are-hiring { |
||||
position: fixed; |
||||
bottom: 50px; |
||||
right: -250px; |
||||
opacity: 0; |
||||
background: orange; |
||||
border-radius: 4px; |
||||
padding: 19px; |
||||
z-index: 200; |
||||
text-decoration: none;
|
||||
text-transform: uppercase; |
||||
color: black; |
||||
transition: 1s opacity, 1s right; |
||||
display: block; |
||||
font-weight: bold; |
||||
}
|
||||
|
||||
.we-are-hiring.active { |
||||
opacity: 1; |
||||
right:25px; |
||||
} |
||||
|
||||
@media only screen and (max-width: 600px) { |
||||
.we-are-hiring { |
||||
display: none; |
||||
} |
||||
} |
||||
\` |
||||
document.body.appendChild(linkEl, document.body.firstChild) |
||||
document.body.appendChild(styleEl, document.body.firstChild) |
||||
setTimeout(() => linkEl.classList.add('active'), 2000) |
||||
</script> |
||||
</body> |
||||
</html> |
||||
`;
|
@ -1,222 +0,0 @@
|
||||
import sqlite3 from 'sqlite3'; |
||||
import { Readable } from 'stream'; |
||||
|
||||
class EntityMap { |
||||
initialized: boolean; |
||||
cols: string[]; |
||||
db: any; |
||||
|
||||
constructor(...args) { |
||||
this.initialized = false; |
||||
this.cols = args.map((arg) => processKey(arg)); |
||||
this.db = new Promise((resolve, reject) => { |
||||
const db = new sqlite3.Database(':memory:'); |
||||
|
||||
const colStatement = |
||||
this.cols.length > 0 |
||||
? this.cols.join(' TEXT, ') + ' TEXT' |
||||
: 'mappingPlaceholder TEXT'; |
||||
db.run(`CREATE TABLE mapping (${colStatement})`, (err) => { |
||||
if (err) { |
||||
console.log(err); |
||||
reject(err); |
||||
} |
||||
resolve(db); |
||||
}); |
||||
}); |
||||
} |
||||
|
||||
async init() { |
||||
if (!this.initialized) { |
||||
this.db = await this.db; |
||||
this.initialized = true; |
||||
} |
||||
} |
||||
|
||||
destroy() { |
||||
if (this.initialized && this.db) { |
||||
this.db.close(); |
||||
} |
||||
} |
||||
|
||||
async addRow(row) { |
||||
if (!this.initialized) { |
||||
throw 'Please initialize first!'; |
||||
} |
||||
|
||||
const cols = Object.keys(row).map((key) => processKey(key)); |
||||
const colStatement = cols.map((key) => `'${key}'`).join(', '); |
||||
const questionMarks = cols.map(() => '?').join(', '); |
||||
|
||||
const promises = []; |
||||
|
||||
for (const col of cols.filter((col) => !this.cols.includes(col))) { |
||||
promises.push( |
||||
new Promise((resolve, reject) => { |
||||
this.db.run(`ALTER TABLE mapping ADD '${col}' TEXT;`, (err) => { |
||||
if (err) { |
||||
console.log(err); |
||||
reject(err); |
||||
} |
||||
this.cols.push(col); |
||||
resolve(true); |
||||
}); |
||||
}) |
||||
); |
||||
} |
||||
|
||||
await Promise.all(promises); |
||||
|
||||
const values = Object.values(row).map((val) => { |
||||
if (typeof val === 'object') { |
||||
return `JSON::${JSON.stringify(val)}`; |
||||
} |
||||
return val; |
||||
}); |
||||
|
||||
return new Promise((resolve, reject) => { |
||||
this.db.run( |
||||
`INSERT INTO mapping (${colStatement}) VALUES (${questionMarks})`, |
||||
values, |
||||
(err) => { |
||||
if (err) { |
||||
console.log(err); |
||||
reject(err); |
||||
} |
||||
resolve(true); |
||||
} |
||||
); |
||||
}); |
||||
} |
||||
|
||||
getRow(col, val, res = []): Promise<Record<string, any>> { |
||||
if (!this.initialized) { |
||||
throw 'Please initialize first!'; |
||||
} |
||||
return new Promise((resolve, reject) => { |
||||
col = processKey(col); |
||||
res = res.map((r) => processKey(r)); |
||||
this.db.get( |
||||
`SELECT ${ |
||||
res.length ? res.join(', ') : '*' |
||||
} FROM mapping WHERE ${col} = ?`,
|
||||
[val], |
||||
(err, rs) => { |
||||
if (err) { |
||||
console.log(err); |
||||
reject(err); |
||||
} |
||||
if (rs) { |
||||
rs = processResponseRow(rs); |
||||
} |
||||
resolve(rs); |
||||
} |
||||
); |
||||
}); |
||||
} |
||||
|
||||
getCount(): Promise<number> { |
||||
if (!this.initialized) { |
||||
throw 'Please initialize first!'; |
||||
} |
||||
return new Promise((resolve, reject) => { |
||||
this.db.get(`SELECT COUNT(*) as count FROM mapping`, (err, rs) => { |
||||
if (err) { |
||||
console.log(err); |
||||
reject(err); |
||||
} |
||||
resolve(rs.count); |
||||
}); |
||||
}); |
||||
} |
||||
|
||||
getStream(res = []): DBStream { |
||||
if (!this.initialized) { |
||||
throw 'Please initialize first!'; |
||||
} |
||||
res = res.map((r) => processKey(r)); |
||||
return new DBStream( |
||||
this.db, |
||||
`SELECT ${res.length ? res.join(', ') : '*'} FROM mapping` |
||||
); |
||||
} |
||||
|
||||
getLimit(limit, offset, res = []): Promise<Record<string, any>[]> { |
||||
if (!this.initialized) { |
||||
throw 'Please initialize first!'; |
||||
} |
||||
return new Promise((resolve, reject) => { |
||||
res = res.map((r) => processKey(r)); |
||||
this.db.all( |
||||
`SELECT ${ |
||||
res.length ? res.join(', ') : '*' |
||||
} FROM mapping LIMIT ${limit} OFFSET ${offset}`,
|
||||
(err, rs) => { |
||||
if (err) { |
||||
console.log(err); |
||||
reject(err); |
||||
} |
||||
for (let row of rs) { |
||||
row = processResponseRow(row); |
||||
} |
||||
resolve(rs); |
||||
} |
||||
); |
||||
}); |
||||
} |
||||
} |
||||
|
||||
class DBStream extends Readable { |
||||
db: any; |
||||
stmt: any; |
||||
sql: any; |
||||
|
||||
constructor(db, sql) { |
||||
super({ objectMode: true }); |
||||
this.db = db; |
||||
this.sql = sql; |
||||
this.stmt = this.db.prepare(this.sql); |
||||
this.on('end', () => this.stmt.finalize()); |
||||
} |
||||
|
||||
_read() { |
||||
let stream = this; |
||||
this.stmt.get(function (err, result) { |
||||
if (err) { |
||||
stream.emit('error', err); |
||||
} else { |
||||
if (result) { |
||||
result = processResponseRow(result); |
||||
} |
||||
stream.push(result || null); |
||||
} |
||||
}); |
||||
} |
||||
} |
||||
|
||||
function processResponseRow(res: any) { |
||||
for (const key of Object.keys(res)) { |
||||
if (res[key] && res[key].startsWith('JSON::')) { |
||||
try { |
||||
res[key] = JSON.parse(res[key].replace('JSON::', '')); |
||||
} catch (e) { |
||||
console.log(e); |
||||
} |
||||
} |
||||
if (revertKey(key) !== key) { |
||||
res[revertKey(key)] = res[key]; |
||||
delete res[key]; |
||||
} |
||||
} |
||||
return res; |
||||
} |
||||
|
||||
function processKey(key) { |
||||
return key.replace(/'/g, "''").replace(/[A-Z]/g, (match) => `_${match}`); |
||||
} |
||||
|
||||
function revertKey(key) { |
||||
return key.replace(/''/g, "'").replace(/_[A-Z]/g, (match) => match[1]); |
||||
} |
||||
|
||||
export default EntityMap; |
@ -1,6 +0,0 @@
|
||||
export abstract class NocoSyncSourceAdapter { |
||||
public abstract init(): Promise<void>; |
||||
public abstract destProjectWrite(): Promise<any>; |
||||
public abstract destSchemaWrite(): Promise<any>; |
||||
public abstract destDataWrite(): Promise<any>; |
||||
} |
@ -1,7 +0,0 @@
|
||||
export abstract class NocoSyncSourceAdapter { |
||||
public abstract init(): Promise<void>; |
||||
public abstract srcSchemaGet(): Promise<any>; |
||||
public abstract srcDataLoad(): Promise<any>; |
||||
public abstract srcDataListen(): Promise<any>; |
||||
public abstract srcDataPoll(): Promise<any>; |
||||
} |
@ -1,238 +0,0 @@
|
||||
const axios = require('axios').default; |
||||
|
||||
const info: any = { |
||||
initialized: false, |
||||
}; |
||||
|
||||
async function initialize(shareId) { |
||||
info.cookie = ''; |
||||
const url = `https://airtable.com/${shareId}`; |
||||
|
||||
try { |
||||
const hreq = await axios |
||||
.get(url, { |
||||
headers: { |
||||
accept: |
||||
'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9', |
||||
'accept-language': 'en-US,en;q=0.9', |
||||
'sec-ch-ua': |
||||
'" Not A;Brand";v="99", "Chromium";v="100", "Google Chrome";v="100"', |
||||
'sec-ch-ua-mobile': '?0', |
||||
'sec-ch-ua-platform': '"Linux"', |
||||
'sec-fetch-dest': 'document', |
||||
'sec-fetch-mode': 'navigate', |
||||
'sec-fetch-site': 'none', |
||||
'sec-fetch-user': '?1', |
||||
'upgrade-insecure-requests': '1', |
||||
'User-Agent': |
||||
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.88 Safari/537.36', |
||||
}, |
||||
referrerPolicy: 'strict-origin-when-cross-origin', |
||||
body: null, |
||||
method: 'GET', |
||||
}) |
||||
.then((response) => { |
||||
for (const ck of response.headers['set-cookie']) { |
||||
info.cookie += ck.split(';')[0] + '; '; |
||||
} |
||||
return response.data; |
||||
}) |
||||
.catch(() => { |
||||
throw { |
||||
message: |
||||
'Invalid Shared Base ID :: Ensure www.airtable.com/<SharedBaseID> is accessible. Refer https://bit.ly/3x0OdXI for details', |
||||
}; |
||||
}); |
||||
|
||||
info.headers = JSON.parse( |
||||
hreq.match(/(?<=var headers =)(.*)(?=;)/g)[0].trim() |
||||
); |
||||
info.link = unicodeToChar(hreq.match(/(?<=fetch\(")(.*)(?=")/g)[0].trim()); |
||||
info.baseInfo = decodeURIComponent(info.link) |
||||
.match(/{(.*)}/g)[0] |
||||
.split('&') |
||||
.reduce((result, el) => { |
||||
try { |
||||
return Object.assign( |
||||
result, |
||||
JSON.parse(el.includes('=') ? el.split('=')[1] : el) |
||||
); |
||||
} catch (e) { |
||||
if (el.includes('=')) { |
||||
return Object.assign(result, { |
||||
[el.split('=')[0]]: el.split('=')[1], |
||||
}); |
||||
} |
||||
} |
||||
}, {}); |
||||
info.baseId = info.baseInfo.applicationId; |
||||
info.initialized = true; |
||||
} catch (e) { |
||||
console.log(e); |
||||
info.initialized = false; |
||||
if (e.message) { |
||||
throw e; |
||||
} else { |
||||
throw { |
||||
message: |
||||
'Error processing Shared Base :: Ensure www.airtable.com/<SharedBaseID> is accessible. Refer https://bit.ly/3x0OdXI for details', |
||||
}; |
||||
} |
||||
} |
||||
} |
||||
|
||||
async function read() { |
||||
if (info.initialized) { |
||||
const resreq = await axios('https://airtable.com' + info.link, { |
||||
headers: { |
||||
accept: '*/*', |
||||
'accept-language': 'en-US,en;q=0.9', |
||||
'sec-ch-ua': |
||||
'" Not A;Brand";v="99", "Chromium";v="100", "Google Chrome";v="100"', |
||||
'sec-ch-ua-mobile': '?0', |
||||
'sec-ch-ua-platform': '"Linux"', |
||||
'sec-fetch-dest': 'empty', |
||||
'sec-fetch-mode': 'cors', |
||||
'sec-fetch-site': 'same-origin', |
||||
'User-Agent': |
||||
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.88 Safari/537.36', |
||||
'x-time-zone': 'Europe/Berlin', |
||||
cookie: info.cookie, |
||||
...info.headers, |
||||
}, |
||||
referrerPolicy: 'no-referrer', |
||||
body: null, |
||||
method: 'GET', |
||||
}) |
||||
.then((response) => { |
||||
return response.data; |
||||
}) |
||||
.catch(() => { |
||||
throw { |
||||
message: |
||||
'Error Reading :: Ensure www.airtable.com/<SharedBaseID> is accessible. Refer https://bit.ly/3x0OdXI for details', |
||||
}; |
||||
}); |
||||
|
||||
return { |
||||
schema: resreq.data, |
||||
baseId: info.baseId, |
||||
baseInfo: info.baseInfo, |
||||
}; |
||||
} else { |
||||
throw { |
||||
message: 'Error Initializing :: please try again !!', |
||||
}; |
||||
} |
||||
} |
||||
|
||||
async function readView(viewId) { |
||||
if (info.initialized) { |
||||
const resreq = await axios( |
||||
`https://airtable.com/v0.3/view/${viewId}/readData?` + |
||||
`stringifiedObjectParams=${encodeURIComponent('{}')}&requestId=${ |
||||
info.baseInfo.requestId |
||||
}&accessPolicy=${encodeURIComponent( |
||||
JSON.stringify({ |
||||
allowedActions: info.baseInfo.allowedActions, |
||||
shareId: info.baseInfo.shareId, |
||||
applicationId: info.baseInfo.applicationId, |
||||
generationNumber: info.baseInfo.generationNumber, |
||||
expires: info.baseInfo.expires, |
||||
signature: info.baseInfo.signature, |
||||
}) |
||||
)}`,
|
||||
{ |
||||
headers: { |
||||
accept: '*/*', |
||||
'accept-language': 'en-US,en;q=0.9', |
||||
'sec-ch-ua': |
||||
'" Not A;Brand";v="99", "Chromium";v="100", "Google Chrome";v="100"', |
||||
'sec-ch-ua-mobile': '?0', |
||||
'sec-ch-ua-platform': '"Linux"', |
||||
'sec-fetch-dest': 'empty', |
||||
'sec-fetch-mode': 'cors', |
||||
'sec-fetch-site': 'same-origin', |
||||
'User-Agent': |
||||
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.88 Safari/537.36', |
||||
'x-time-zone': 'Europe/Berlin', |
||||
cookie: info.cookie, |
||||
...info.headers, |
||||
}, |
||||
referrerPolicy: 'no-referrer', |
||||
body: null, |
||||
method: 'GET', |
||||
} |
||||
) |
||||
.then((response) => { |
||||
return response.data; |
||||
}) |
||||
.catch(() => { |
||||
throw { |
||||
message: |
||||
'Error Reading View :: Ensure www.airtable.com/<SharedBaseID> is accessible. Refer https://bit.ly/3x0OdXI for details', |
||||
}; |
||||
}); |
||||
return { view: resreq.data }; |
||||
} else { |
||||
throw { |
||||
message: 'Error Initializing :: please try again !!', |
||||
}; |
||||
} |
||||
} |
||||
|
||||
async function readTemplate(templateId) { |
||||
if (!info.initialized) { |
||||
await initialize('shrO8aYf3ybwSdDKn'); |
||||
} |
||||
const resreq = await axios( |
||||
`https://www.airtable.com/v0.3/exploreApplications/${templateId}`, |
||||
{ |
||||
headers: { |
||||
accept: '*/*', |
||||
'accept-language': 'en-US,en;q=0.9', |
||||
'sec-ch-ua': |
||||
'" Not A;Brand";v="99", "Chromium";v="100", "Google Chrome";v="100"', |
||||
'sec-ch-ua-mobile': '?0', |
||||
'sec-ch-ua-platform': '"Linux"', |
||||
'sec-fetch-dest': 'empty', |
||||
'sec-fetch-mode': 'cors', |
||||
'sec-fetch-site': 'same-origin', |
||||
'User-Agent': |
||||
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.88 Safari/537.36', |
||||
'x-time-zone': 'Europe/Berlin', |
||||
cookie: info.cookie, |
||||
...info.headers, |
||||
}, |
||||
referrer: 'https://www.airtable.com/', |
||||
referrerPolicy: 'same-origin', |
||||
body: null, |
||||
method: 'GET', |
||||
mode: 'cors', |
||||
credentials: 'include', |
||||
} |
||||
) |
||||
.then((response) => { |
||||
return response.data; |
||||
}) |
||||
.catch(() => { |
||||
throw { |
||||
message: |
||||
'Error Fetching :: Ensure www.airtable.com/templates/featured/<TemplateID> is accessible.', |
||||
}; |
||||
}); |
||||
return { template: resreq }; |
||||
} |
||||
|
||||
function unicodeToChar(text) { |
||||
return text.replace(/\\u[\dA-F]{4}/gi, function (match) { |
||||
return String.fromCharCode(parseInt(match.replace(/\\u/g, ''), 16)); |
||||
}); |
||||
} |
||||
|
||||
export default { |
||||
initialize, |
||||
read, |
||||
readView, |
||||
readTemplate, |
||||
}; |
File diff suppressed because it is too large
Load Diff
@ -1,338 +0,0 @@
|
||||
import { AirtableBase } from 'airtable/lib/airtable_base'; |
||||
import { Api, RelationTypes, TableType, UITypes } from 'nocodb-sdk'; |
||||
import EntityMap from './EntityMap'; |
||||
|
||||
const BULK_DATA_BATCH_SIZE = 500; |
||||
const ASSOC_BULK_DATA_BATCH_SIZE = 1000; |
||||
const BULK_PARALLEL_PROCESS = 5; |
||||
|
||||
async function readAllData({ |
||||
table, |
||||
fields, |
||||
base, |
||||
logBasic = (_str) => {}, |
||||
}: { |
||||
table: { title?: string }; |
||||
fields?; |
||||
base: AirtableBase; |
||||
logBasic?: (string) => void; |
||||
logDetailed?: (string) => void; |
||||
}): Promise<EntityMap> { |
||||
return new Promise((resolve, reject) => { |
||||
let data = null; |
||||
|
||||
const selectParams: any = { |
||||
pageSize: 100, |
||||
}; |
||||
|
||||
if (fields) selectParams.fields = fields; |
||||
|
||||
base(table.title) |
||||
.select(selectParams) |
||||
.eachPage( |
||||
async function page(records, fetchNextPage) { |
||||
if (!data) { |
||||
data = new EntityMap(); |
||||
await data.init(); |
||||
} |
||||
|
||||
for await (const record of records) { |
||||
await data.addRow({ id: record.id, ...record.fields }); |
||||
} |
||||
|
||||
const tmpLength = await data.getCount(); |
||||
|
||||
logBasic( |
||||
`:: Reading '${table.title}' data :: ${Math.max( |
||||
1, |
||||
tmpLength - records.length |
||||
)} - ${tmpLength}` |
||||
); |
||||
|
||||
// To fetch the next page of records, call `fetchNextPage`.
|
||||
// If there are more records, `page` will get called again.
|
||||
// If there are no more records, `done` will get called.
|
||||
fetchNextPage(); |
||||
}, |
||||
async function done(err) { |
||||
if (err) { |
||||
console.error(err); |
||||
return reject(err); |
||||
} |
||||
resolve(data); |
||||
} |
||||
); |
||||
}); |
||||
} |
||||
|
||||
export async function importData({ |
||||
projectName, |
||||
table, |
||||
base, |
||||
api, |
||||
nocoBaseDataProcessing_v2, |
||||
sDB, |
||||
logDetailed = (_str) => {}, |
||||
logBasic = (_str) => {}, |
||||
}: { |
||||
projectName: string; |
||||
table: { title?: string; id?: string }; |
||||
fields?; |
||||
base: AirtableBase; |
||||
logBasic: (string) => void; |
||||
logDetailed: (string) => void; |
||||
api: Api<any>; |
||||
nocoBaseDataProcessing_v2; |
||||
sDB; |
||||
}): Promise<EntityMap> { |
||||
try { |
||||
// @ts-ignore
|
||||
const records = await readAllData({ |
||||
table, |
||||
base, |
||||
logDetailed, |
||||
logBasic, |
||||
}); |
||||
|
||||
await new Promise(async (resolve) => { |
||||
const readable = records.getStream(); |
||||
const allRecordsCount = await records.getCount(); |
||||
const promises = []; |
||||
let tempData = []; |
||||
let importedCount = 0; |
||||
let activeProcess = 0; |
||||
readable.on('data', async (record) => { |
||||
promises.push( |
||||
new Promise(async (resolve) => { |
||||
activeProcess++; |
||||
if (activeProcess >= BULK_PARALLEL_PROCESS) readable.pause(); |
||||
const { id: rid, ...fields } = record; |
||||
const r = await nocoBaseDataProcessing_v2(sDB, table, { |
||||
id: rid, |
||||
fields, |
||||
}); |
||||
tempData.push(r); |
||||
|
||||
if (tempData.length >= BULK_DATA_BATCH_SIZE) { |
||||
let insertArray = tempData.splice(0, tempData.length); |
||||
await api.dbTableRow.bulkCreate( |
||||
'nc', |
||||
projectName, |
||||
table.id, |
||||
insertArray |
||||
); |
||||
logBasic( |
||||
`:: Importing '${ |
||||
table.title |
||||
}' data :: ${importedCount} - ${Math.min( |
||||
importedCount + BULK_DATA_BATCH_SIZE, |
||||
allRecordsCount |
||||
)}` |
||||
); |
||||
importedCount += insertArray.length; |
||||
insertArray = []; |
||||
} |
||||
activeProcess--; |
||||
if (activeProcess < BULK_PARALLEL_PROCESS) readable.resume(); |
||||
resolve(true); |
||||
}) |
||||
); |
||||
}); |
||||
readable.on('end', async () => { |
||||
await Promise.all(promises); |
||||
if (tempData.length > 0) { |
||||
await api.dbTableRow.bulkCreate( |
||||
'nc', |
||||
projectName, |
||||
table.id, |
||||
tempData |
||||
); |
||||
logBasic( |
||||
`:: Importing '${ |
||||
table.title |
||||
}' data :: ${importedCount} - ${Math.min( |
||||
importedCount + BULK_DATA_BATCH_SIZE, |
||||
allRecordsCount |
||||
)}` |
||||
); |
||||
importedCount += tempData.length; |
||||
tempData = []; |
||||
} |
||||
resolve(true); |
||||
}); |
||||
}); |
||||
|
||||
return records; |
||||
} catch (e) { |
||||
console.log(e); |
||||
return null; |
||||
} |
||||
} |
||||
|
||||
export async function importLTARData({ |
||||
table, |
||||
fields, |
||||
base, |
||||
api, |
||||
projectName, |
||||
insertedAssocRef = {}, |
||||
logDetailed = (_str) => {}, |
||||
logBasic = (_str) => {}, |
||||
records, |
||||
atNcAliasRef, |
||||
ncLinkMappingTable, |
||||
}: { |
||||
projectName: string; |
||||
table: { title?: string; id?: string }; |
||||
fields; |
||||
base: AirtableBase; |
||||
logDetailed: (string) => void; |
||||
logBasic: (string) => void; |
||||
api: Api<any>; |
||||
insertedAssocRef: { [assocTableId: string]: boolean }; |
||||
records?: EntityMap; |
||||
atNcAliasRef: { |
||||
[ncTableId: string]: { |
||||
[ncTitle: string]: string; |
||||
}; |
||||
}; |
||||
ncLinkMappingTable: Record<string, Record<string, any>>[]; |
||||
}) { |
||||
const assocTableMetas: Array<{ |
||||
modelMeta: { id?: string; title?: string }; |
||||
colMeta: { title?: string }; |
||||
curCol: { title?: string }; |
||||
refCol: { title?: string }; |
||||
}> = []; |
||||
const allData = |
||||
records || |
||||
(await readAllData({ |
||||
table, |
||||
fields, |
||||
base, |
||||
logDetailed, |
||||
logBasic, |
||||
})); |
||||
|
||||
const modelMeta: any = await api.dbTable.read(table.id); |
||||
|
||||
for (const colMeta of modelMeta.columns) { |
||||
// skip columns which are not LTAR and Many to many
|
||||
if ( |
||||
colMeta.uidt !== UITypes.LinkToAnotherRecord || |
||||
colMeta.colOptions.type !== RelationTypes.MANY_TO_MANY |
||||
) { |
||||
continue; |
||||
} |
||||
|
||||
// skip if already inserted
|
||||
if (colMeta.colOptions.fk_mm_model_id in insertedAssocRef) continue; |
||||
|
||||
// self links: skip if the column under consideration is the add-on column NocoDB creates
|
||||
if (ncLinkMappingTable.every((a) => a.nc.title !== colMeta.title)) continue; |
||||
|
||||
// mark as inserted
|
||||
insertedAssocRef[colMeta.colOptions.fk_mm_model_id] = true; |
||||
|
||||
const assocModelMeta: TableType = (await api.dbTable.read( |
||||
colMeta.colOptions.fk_mm_model_id |
||||
)) as any; |
||||
|
||||
// extract associative table and columns meta
|
||||
assocTableMetas.push({ |
||||
modelMeta: assocModelMeta, |
||||
colMeta, |
||||
curCol: assocModelMeta.columns.find( |
||||
(c) => c.id === colMeta.colOptions.fk_mm_child_column_id |
||||
), |
||||
refCol: assocModelMeta.columns.find( |
||||
(c) => c.id === colMeta.colOptions.fk_mm_parent_column_id |
||||
), |
||||
}); |
||||
} |
||||
|
||||
let nestedLinkCnt = 0; |
||||
// Iterate over all related M2M associative table
|
||||
for await (const assocMeta of assocTableMetas) { |
||||
let assocTableData = []; |
||||
let importedCount = 0; |
||||
|
||||
// extract insert data from records
|
||||
await new Promise((resolve) => { |
||||
const promises = []; |
||||
const readable = allData.getStream(); |
||||
let activeProcess = 0; |
||||
readable.on('data', async (record) => { |
||||
promises.push( |
||||
new Promise(async (resolve) => { |
||||
activeProcess++; |
||||
if (activeProcess >= BULK_PARALLEL_PROCESS) readable.pause(); |
||||
const { id: _atId, ...rec } = record; |
||||
|
||||
// todo: use actual alias instead of sanitized
|
||||
assocTableData.push( |
||||
...( |
||||
rec?.[atNcAliasRef[table.id][assocMeta.colMeta.title]] || [] |
||||
).map((id) => ({ |
||||
[assocMeta.curCol.title]: record.id, |
||||
[assocMeta.refCol.title]: id, |
||||
})) |
||||
); |
||||
|
||||
if (assocTableData.length >= ASSOC_BULK_DATA_BATCH_SIZE) { |
||||
let insertArray = assocTableData.splice(0, assocTableData.length); |
||||
logBasic( |
||||
`:: Importing '${ |
||||
table.title |
||||
}' LTAR data :: ${importedCount} - ${Math.min( |
||||
importedCount + ASSOC_BULK_DATA_BATCH_SIZE, |
||||
insertArray.length |
||||
)}` |
||||
); |
||||
|
||||
await api.dbTableRow.bulkCreate( |
||||
'nc', |
||||
projectName, |
||||
assocMeta.modelMeta.id, |
||||
insertArray |
||||
); |
||||
|
||||
importedCount += insertArray.length; |
||||
insertArray = []; |
||||
} |
||||
activeProcess--; |
||||
if (activeProcess < BULK_PARALLEL_PROCESS) readable.resume(); |
||||
resolve(true); |
||||
}) |
||||
); |
||||
}); |
||||
readable.on('end', async () => { |
||||
await Promise.all(promises); |
||||
if (assocTableData.length >= 0) { |
||||
logBasic( |
||||
`:: Importing '${ |
||||
table.title |
||||
}' LTAR data :: ${importedCount} - ${Math.min( |
||||
importedCount + ASSOC_BULK_DATA_BATCH_SIZE, |
||||
assocTableData.length |
||||
)}` |
||||
); |
||||
|
||||
await api.dbTableRow.bulkCreate( |
||||
'nc', |
||||
projectName, |
||||
assocMeta.modelMeta.id, |
||||
assocTableData |
||||
); |
||||
|
||||
importedCount += assocTableData.length; |
||||
assocTableData = []; |
||||
} |
||||
resolve(true); |
||||
}); |
||||
}); |
||||
|
||||
nestedLinkCnt += importedCount; |
||||
} |
||||
return nestedLinkCnt; |
||||
} |
@ -1,31 +0,0 @@
|
||||
export const mapTbl = {}; |
||||
|
||||
// static mapping records between aTblId && ncId
|
||||
export const addToMappingTbl = function addToMappingTbl( |
||||
aTblId, |
||||
ncId, |
||||
ncName, |
||||
parent? |
||||
) { |
||||
mapTbl[aTblId] = { |
||||
ncId: ncId, |
||||
ncParent: parent, |
||||
// name added to assist in quick debug
|
||||
ncName: ncName, |
||||
}; |
||||
}; |
||||
|
||||
// get NcID from airtable ID
|
||||
export const getNcIdFromAtId = function getNcIdFromAtId(aId) { |
||||
return mapTbl[aId]?.ncId; |
||||
}; |
||||
|
||||
// get nc Parent from airtable ID
|
||||
export const getNcParentFromAtId = function getNcParentFromAtId(aId) { |
||||
return mapTbl[aId]?.ncParent; |
||||
}; |
||||
|
||||
// get nc-title from airtable ID
|
||||
export const getNcNameFromAtId = function getNcNameFromAtId(aId) { |
||||
return mapTbl[aId]?.ncName; |
||||
}; |
@ -1,138 +0,0 @@
|
||||
import { Request, Router } from 'express'; |
||||
// import { Queue } from 'bullmq';
|
||||
// import axios from 'axios';
|
||||
import catchError, { NcError } from '../../meta/helpers/catchError'; |
||||
import { Server } from 'socket.io'; |
||||
import NocoJobs from '../../jobs/NocoJobs'; |
||||
import job, { AirtableSyncConfig } from './helpers/job'; |
||||
import SyncSource from '../../models/SyncSource'; |
||||
import Noco from '../../Noco'; |
||||
import { genJwt } from '../userApi/helpers'; |
||||
const AIRTABLE_IMPORT_JOB = 'AIRTABLE_IMPORT_JOB'; |
||||
const AIRTABLE_PROGRESS_JOB = 'AIRTABLE_PROGRESS_JOB'; |
||||
|
||||
enum SyncStatus { |
||||
PROGRESS = 'PROGRESS', |
||||
COMPLETED = 'COMPLETED', |
||||
FAILED = 'FAILED', |
||||
} |
||||
|
||||
export default ( |
||||
router: Router, |
||||
sv: Server, |
||||
jobs: { [id: string]: { last_message: any } } |
||||
) => { |
||||
// add importer job handler and progress notification job handler
|
||||
NocoJobs.jobsMgr.addJobWorker(AIRTABLE_IMPORT_JOB, job); |
||||
NocoJobs.jobsMgr.addJobWorker( |
||||
AIRTABLE_PROGRESS_JOB, |
||||
({ payload, progress }) => { |
||||
sv.to(payload?.id).emit('progress', { |
||||
msg: progress?.msg, |
||||
level: progress?.level, |
||||
status: progress?.status, |
||||
}); |
||||
|
||||
if (payload?.id in jobs) { |
||||
jobs[payload?.id].last_message = { |
||||
msg: progress?.msg, |
||||
level: progress?.level, |
||||
status: progress?.status, |
||||
}; |
||||
} |
||||
} |
||||
); |
||||
|
||||
NocoJobs.jobsMgr.addProgressCbk(AIRTABLE_IMPORT_JOB, (payload, progress) => { |
||||
NocoJobs.jobsMgr.add(AIRTABLE_PROGRESS_JOB, { |
||||
payload, |
||||
progress: { |
||||
msg: progress?.msg, |
||||
level: progress?.level, |
||||
status: progress?.status, |
||||
}, |
||||
}); |
||||
}); |
||||
NocoJobs.jobsMgr.addSuccessCbk(AIRTABLE_IMPORT_JOB, (payload) => { |
||||
NocoJobs.jobsMgr.add(AIRTABLE_PROGRESS_JOB, { |
||||
payload, |
||||
progress: { |
||||
msg: 'Complete!', |
||||
status: SyncStatus.COMPLETED, |
||||
}, |
||||
}); |
||||
delete jobs[payload?.id]; |
||||
}); |
||||
NocoJobs.jobsMgr.addFailureCbk(AIRTABLE_IMPORT_JOB, (payload, error: any) => { |
||||
NocoJobs.jobsMgr.add(AIRTABLE_PROGRESS_JOB, { |
||||
payload, |
||||
progress: { |
||||
msg: error?.message || 'Failed due to some internal error', |
||||
status: SyncStatus.FAILED, |
||||
}, |
||||
}); |
||||
delete jobs[payload?.id]; |
||||
}); |
||||
|
||||
router.post( |
||||
'/api/v1/db/meta/import/airtable', |
||||
catchError((req, res) => { |
||||
NocoJobs.jobsMgr.add(AIRTABLE_IMPORT_JOB, { |
||||
id: req.query.id, |
||||
...req.body, |
||||
}); |
||||
res.json({}); |
||||
}) |
||||
); |
||||
router.post( |
||||
'/api/v1/db/meta/syncs/:syncId/trigger', |
||||
catchError(async (req: Request, res) => { |
||||
if (req.params.syncId in jobs) { |
||||
NcError.badRequest('Sync already in progress'); |
||||
} |
||||
|
||||
const syncSource = await SyncSource.get(req.params.syncId); |
||||
|
||||
const user = await syncSource.getUser(); |
||||
const token = genJwt(user, Noco.getConfig()); |
||||
|
||||
// Treat default baseUrl as siteUrl from req object
|
||||
let baseURL = (req as any).ncSiteUrl; |
||||
|
||||
// if environment value avail use it
|
||||
// or if it's docker construct using `PORT`
|
||||
if (process.env.NC_BASEURL_INTERNAL) { |
||||
baseURL = process.env.NC_BASEURL_INTERNAL; |
||||
} else if (process.env.NC_DOCKER) { |
||||
baseURL = `http://localhost:${process.env.PORT || 8080}`; |
||||
} |
||||
|
||||
setTimeout(() => { |
||||
NocoJobs.jobsMgr.add<AirtableSyncConfig>(AIRTABLE_IMPORT_JOB, { |
||||
id: req.params.syncId, |
||||
...(syncSource?.details || {}), |
||||
projectId: syncSource.project_id, |
||||
baseId: syncSource.base_id, |
||||
authToken: token, |
||||
baseURL, |
||||
}); |
||||
}, 1000); |
||||
|
||||
jobs[req.params.syncId] = { |
||||
last_message: { |
||||
msg: 'Sync started', |
||||
}, |
||||
}; |
||||
res.json({}); |
||||
}) |
||||
); |
||||
router.post( |
||||
'/api/v1/db/meta/syncs/:syncId/abort', |
||||
catchError(async (req: Request, res) => { |
||||
if (req.params.syncId in jobs) { |
||||
delete jobs[req.params.syncId]; |
||||
} |
||||
res.json({}); |
||||
}) |
||||
); |
||||
}; |
@ -1,69 +0,0 @@
|
||||
import { Request, Response, Router } from 'express'; |
||||
|
||||
import SyncSource from '../../../models/SyncSource'; |
||||
import { T } from 'nc-help'; |
||||
import { PagedResponseImpl } from '../../helpers/PagedResponse'; |
||||
import ncMetaAclMw from '../../helpers/ncMetaAclMw'; |
||||
import Project from '../../../models/Project'; |
||||
|
||||
export async function syncSourceList(req: Request, res: Response) { |
||||
// todo: pagination
|
||||
res.json( |
||||
new PagedResponseImpl( |
||||
await SyncSource.list(req.params.projectId, req.params.baseId) |
||||
) |
||||
); |
||||
} |
||||
|
||||
export async function syncCreate(req: Request, res: Response) { |
||||
T.emit('evt', { evt_type: 'webhooks:created' }); |
||||
const project = await Project.getWithInfo(req.params.projectId); |
||||
|
||||
const sync = await SyncSource.insert({ |
||||
...req.body, |
||||
fk_user_id: (req as any).user.id, |
||||
base_id: req.params.baseId ? req.params.baseId : project.bases[0].id, |
||||
project_id: req.params.projectId, |
||||
}); |
||||
res.json(sync); |
||||
} |
||||
|
||||
export async function syncDelete(req: Request, res: Response<any>) { |
||||
T.emit('evt', { evt_type: 'webhooks:deleted' }); |
||||
res.json(await SyncSource.delete(req.params.syncId)); |
||||
} |
||||
|
||||
export async function syncUpdate(req: Request, res: Response) { |
||||
T.emit('evt', { evt_type: 'webhooks:updated' }); |
||||
|
||||
res.json(await SyncSource.update(req.params.syncId, req.body)); |
||||
} |
||||
|
||||
const router = Router({ mergeParams: true }); |
||||
|
||||
router.get( |
||||
'/api/v1/db/meta/projects/:projectId/syncs', |
||||
ncMetaAclMw(syncSourceList, 'syncSourceList') |
||||
); |
||||
router.post( |
||||
'/api/v1/db/meta/projects/:projectId/syncs', |
||||
ncMetaAclMw(syncCreate, 'syncSourceCreate') |
||||
); |
||||
router.get( |
||||
'/api/v1/db/meta/projects/:projectId/syncs/:baseId', |
||||
ncMetaAclMw(syncSourceList, 'syncSourceList') |
||||
); |
||||
router.post( |
||||
'/api/v1/db/meta/projects/:projectId/syncs/:baseId', |
||||
ncMetaAclMw(syncCreate, 'syncSourceCreate') |
||||
); |
||||
router.delete( |
||||
'/api/v1/db/meta/syncs/:syncId', |
||||
ncMetaAclMw(syncDelete, 'syncSourceDelete') |
||||
); |
||||
router.patch( |
||||
'/api/v1/db/meta/syncs/:syncId', |
||||
ncMetaAclMw(syncUpdate, 'syncSourceUpdate') |
||||
); |
||||
|
||||
export default router; |
@ -1,23 +0,0 @@
|
||||
import * as jwt from 'jsonwebtoken'; |
||||
import crypto from 'crypto'; |
||||
import User from '../../models/User'; |
||||
import { NcConfig } from '../../../interface/config'; |
||||
|
||||
export function genJwt(user: User, config: NcConfig) { |
||||
return jwt.sign( |
||||
{ |
||||
email: user.email, |
||||
firstname: user.firstname, |
||||
lastname: user.lastname, |
||||
id: user.id, |
||||
roles: user.roles, |
||||
token_version: user.token_version, |
||||
}, |
||||
config.auth.jwt.secret, |
||||
config.auth.jwt.options |
||||
); |
||||
} |
||||
|
||||
export function randomTokenString(): string { |
||||
return crypto.randomBytes(40).toString('hex'); |
||||
} |
@ -1 +0,0 @@
|
||||
export * from './userApis'; |
@ -1,283 +0,0 @@
|
||||
import User from '../../../models/User'; |
||||
import { v4 as uuidv4 } from 'uuid'; |
||||
import { promisify } from 'util'; |
||||
|
||||
import bcrypt from 'bcryptjs'; |
||||
import Noco from '../../../Noco'; |
||||
import { CacheScope, MetaTable } from '../../../utils/globals'; |
||||
import ProjectUser from '../../../models/ProjectUser'; |
||||
import { validatePassword } from 'nocodb-sdk'; |
||||
import boxen from 'boxen'; |
||||
import NocoCache from '../../../cache/NocoCache'; |
||||
import { T } from 'nc-help'; |
||||
|
||||
const { isEmail } = require('validator'); |
||||
const rolesLevel = { owner: 0, creator: 1, editor: 2, commenter: 3, viewer: 4 }; |
||||
|
||||
export default async function initAdminFromEnv(_ncMeta = Noco.ncMeta) { |
||||
if (process.env.NC_ADMIN_EMAIL && process.env.NC_ADMIN_PASSWORD) { |
||||
if (!isEmail(process.env.NC_ADMIN_EMAIL?.trim())) { |
||||
console.log( |
||||
'\n', |
||||
boxen( |
||||
`Provided admin email '${process.env.NC_ADMIN_EMAIL}' is not valid`, |
||||
{ |
||||
title: 'Invalid admin email', |
||||
padding: 1, |
||||
borderStyle: 'double', |
||||
titleAlignment: 'center', |
||||
borderColor: 'red', |
||||
} |
||||
), |
||||
'\n' |
||||
); |
||||
process.exit(1); |
||||
} |
||||
|
||||
const { valid, error, hint } = validatePassword( |
||||
process.env.NC_ADMIN_PASSWORD |
||||
); |
||||
if (!valid) { |
||||
console.log( |
||||
'\n', |
||||
boxen(`${error}${hint ? `\n\n${hint}` : ''}`, { |
||||
title: 'Invalid admin password', |
||||
padding: 1, |
||||
borderStyle: 'double', |
||||
titleAlignment: 'center', |
||||
borderColor: 'red', |
||||
}), |
||||
'\n' |
||||
); |
||||
process.exit(1); |
||||
} |
||||
|
||||
let ncMeta; |
||||
try { |
||||
ncMeta = await _ncMeta.startTransaction(); |
||||
const email = process.env.NC_ADMIN_EMAIL.toLowerCase().trim(); |
||||
|
||||
const salt = await promisify(bcrypt.genSalt)(10); |
||||
const password = await promisify(bcrypt.hash)( |
||||
process.env.NC_ADMIN_PASSWORD, |
||||
salt |
||||
); |
||||
const email_verification_token = uuidv4(); |
||||
const roles = 'user,super'; |
||||
|
||||
// if super admin not present
|
||||
if (await User.isFirst(ncMeta)) { |
||||
// roles = 'owner,creator,editor'
|
||||
T.emit('evt', { |
||||
evt_type: 'project:invite', |
||||
count: 1, |
||||
}); |
||||
|
||||
await User.insert( |
||||
{ |
||||
firstname: '', |
||||
lastname: '', |
||||
email, |
||||
salt, |
||||
password, |
||||
email_verification_token, |
||||
roles, |
||||
}, |
||||
ncMeta |
||||
); |
||||
} else { |
||||
const salt = await promisify(bcrypt.genSalt)(10); |
||||
const password = await promisify(bcrypt.hash)( |
||||
process.env.NC_ADMIN_PASSWORD, |
||||
salt |
||||
); |
||||
const email_verification_token = uuidv4(); |
||||
const superUser = await ncMeta.metaGet2(null, null, MetaTable.USERS, { |
||||
roles: 'user,super', |
||||
}); |
||||
|
||||
if (!superUser?.id) { |
||||
const existingUserWithNewEmail = await User.getByEmail(email, ncMeta); |
||||
if (existingUserWithNewEmail?.id) { |
||||
// clear cache
|
||||
await NocoCache.delAll( |
||||
CacheScope.USER, |
||||
`${existingUserWithNewEmail.email}___*` |
||||
); |
||||
await NocoCache.del( |
||||
`${CacheScope.USER}:${existingUserWithNewEmail.id}` |
||||
); |
||||
await NocoCache.del( |
||||
`${CacheScope.USER}:${existingUserWithNewEmail.email}` |
||||
); |
||||
|
||||
// Update email and password of super admin account
|
||||
await User.update( |
||||
existingUserWithNewEmail.id, |
||||
{ |
||||
salt, |
||||
email, |
||||
password, |
||||
email_verification_token, |
||||
token_version: null, |
||||
refresh_token: null, |
||||
roles, |
||||
}, |
||||
ncMeta |
||||
); |
||||
} else { |
||||
T.emit('evt', { |
||||
evt_type: 'project:invite', |
||||
count: 1, |
||||
}); |
||||
|
||||
await User.insert( |
||||
{ |
||||
firstname: '', |
||||
lastname: '', |
||||
email, |
||||
salt, |
||||
password, |
||||
email_verification_token, |
||||
roles, |
||||
}, |
||||
ncMeta |
||||
); |
||||
} |
||||
} else if (email !== superUser.email) { |
||||
// update admin email and password and migrate projects
|
||||
// if user already present and associated with some project
|
||||
|
||||
// check user account already present with the new admin email
|
||||
const existingUserWithNewEmail = await User.getByEmail(email, ncMeta); |
||||
|
||||
if (existingUserWithNewEmail?.id) { |
||||
// get all project access belongs to the existing account
|
||||
// and migrate to the admin account
|
||||
const existingUserProjects = await ncMeta.metaList2( |
||||
null, |
||||
null, |
||||
MetaTable.PROJECT_USERS, |
||||
{ |
||||
condition: { fk_user_id: existingUserWithNewEmail.id }, |
||||
} |
||||
); |
||||
|
||||
for (const existingUserProject of existingUserProjects) { |
||||
const userProject = await ProjectUser.get( |
||||
existingUserProject.project_id, |
||||
superUser.id, |
||||
ncMeta |
||||
); |
||||
|
||||
// if admin user already have access to the project
|
||||
// then update role based on the highest access level
|
||||
if (userProject) { |
||||
if ( |
||||
rolesLevel[userProject.roles] > |
||||
rolesLevel[existingUserProject.roles] |
||||
) { |
||||
await ProjectUser.update( |
||||
userProject.project_id, |
||||
superUser.id, |
||||
existingUserProject.roles, |
||||
ncMeta |
||||
); |
||||
} |
||||
} else { |
||||
// if super doesn't have access then add the access
|
||||
await ProjectUser.insert( |
||||
{ |
||||
...existingUserProject, |
||||
fk_user_id: superUser.id, |
||||
}, |
||||
ncMeta |
||||
); |
||||
} |
||||
// delete the old project access entry from DB
|
||||
await ProjectUser.delete( |
||||
existingUserProject.project_id, |
||||
existingUserProject.fk_user_id, |
||||
ncMeta |
||||
); |
||||
} |
||||
|
||||
// delete existing user
|
||||
await ncMeta.metaDelete( |
||||
null, |
||||
null, |
||||
MetaTable.USERS, |
||||
existingUserWithNewEmail.id |
||||
); |
||||
|
||||
// clear cache
|
||||
await NocoCache.delAll( |
||||
CacheScope.USER, |
||||
`${existingUserWithNewEmail.email}___*` |
||||
); |
||||
await NocoCache.del( |
||||
`${CacheScope.USER}:${existingUserWithNewEmail.id}` |
||||
); |
||||
await NocoCache.del( |
||||
`${CacheScope.USER}:${existingUserWithNewEmail.email}` |
||||
); |
||||
|
||||
// Update email and password of super admin account
|
||||
await User.update( |
||||
superUser.id, |
||||
{ |
||||
salt, |
||||
email, |
||||
password, |
||||
email_verification_token, |
||||
token_version: null, |
||||
refresh_token: null, |
||||
}, |
||||
ncMeta |
||||
); |
||||
} else { |
||||
// if email's are not different update the password and hash
|
||||
await User.update( |
||||
superUser.id, |
||||
{ |
||||
salt, |
||||
email, |
||||
password, |
||||
email_verification_token, |
||||
token_version: null, |
||||
refresh_token: null, |
||||
}, |
||||
ncMeta |
||||
); |
||||
} |
||||
} else { |
||||
const newPasswordHash = await promisify(bcrypt.hash)( |
||||
process.env.NC_ADMIN_PASSWORD, |
||||
superUser.salt |
||||
); |
||||
|
||||
if (newPasswordHash !== superUser.password) { |
||||
// if email's are same and passwords are different
|
||||
// then update the password and token version
|
||||
await User.update( |
||||
superUser.id, |
||||
{ |
||||
salt, |
||||
password, |
||||
email_verification_token, |
||||
token_version: null, |
||||
refresh_token: null, |
||||
}, |
||||
ncMeta |
||||
); |
||||
} |
||||
} |
||||
} |
||||
await ncMeta.commit(); |
||||
} catch (e) { |
||||
console.log('Error occurred while updating/creating admin user'); |
||||
console.log(e); |
||||
await ncMeta.rollback(e); |
||||
} |
||||
} |
||||
} |
@ -1,336 +0,0 @@
|
||||
import { OrgUserRoles } from 'nocodb-sdk'; |
||||
import User from '../../models/User'; |
||||
import ProjectUser from '../../models/ProjectUser'; |
||||
import { promisify } from 'util'; |
||||
import { Strategy as CustomStrategy } from 'passport-custom'; |
||||
import passport from 'passport'; |
||||
import passportJWT from 'passport-jwt'; |
||||
import { Strategy as AuthTokenStrategy } from 'passport-auth-token'; |
||||
import { Strategy as GoogleStrategy } from 'passport-google-oauth20'; |
||||
|
||||
const PassportLocalStrategy = require('passport-local').Strategy; |
||||
const ExtractJwt = passportJWT.ExtractJwt; |
||||
const JwtStrategy = passportJWT.Strategy; |
||||
|
||||
const jwtOptions = { |
||||
jwtFromRequest: ExtractJwt.fromHeader('xc-auth'), |
||||
}; |
||||
|
||||
import bcrypt from 'bcryptjs'; |
||||
import Project from '../../models/Project'; |
||||
import NocoCache from '../../cache/NocoCache'; |
||||
import { CacheGetType, CacheScope } from '../../utils/globals'; |
||||
import ApiToken from '../../models/ApiToken'; |
||||
import Noco from '../../Noco'; |
||||
import Plugin from '../../models/Plugin'; |
||||
import { registerNewUserIfAllowed } from './userApis'; |
||||
|
||||
export function initStrategies(router): void { |
||||
passport.use( |
||||
'authtoken', |
||||
new AuthTokenStrategy( |
||||
{ headerFields: ['xc-token'], passReqToCallback: true }, |
||||
(req, token, done) => { |
||||
ApiToken.getByToken(token) |
||||
.then((apiToken) => { |
||||
if (!apiToken) { |
||||
return done({ msg: 'Invalid token' }); |
||||
} |
||||
|
||||
if (!apiToken.fk_user_id) return done(null, { roles: 'editor' }); |
||||
User.get(apiToken.fk_user_id) |
||||
.then((user) => { |
||||
user['is_api_token'] = true; |
||||
if (req.ncProjectId) { |
||||
ProjectUser.get(req.ncProjectId, user.id) |
||||
.then(async (projectUser) => { |
||||
user.roles = projectUser?.roles || user.roles; |
||||
user.roles = |
||||
user.roles === 'owner' ? 'owner,creator' : user.roles; |
||||
// + (user.roles ? `,${user.roles}` : '');
|
||||
// todo : cache
|
||||
// await NocoCache.set(`${CacheScope.USER}:${key}`, user);
|
||||
done(null, user); |
||||
}) |
||||
.catch((e) => done(e)); |
||||
} else { |
||||
return done(null, user); |
||||
} |
||||
}) |
||||
.catch((e) => { |
||||
console.log(e); |
||||
done({ msg: 'User not found' }); |
||||
}); |
||||
}) |
||||
.catch((e) => { |
||||
console.log(e); |
||||
done({ msg: 'Invalid token' }); |
||||
}); |
||||
} |
||||
) |
||||
); |
||||
|
||||
passport.serializeUser(function ( |
||||
{ |
||||
id, |
||||
email, |
||||
email_verified, |
||||
roles: _roles, |
||||
provider, |
||||
firstname, |
||||
lastname, |
||||
isAuthorized, |
||||
isPublicBase, |
||||
token_version, |
||||
}, |
||||
done |
||||
) { |
||||
const roles = (_roles || '') |
||||
.split(',') |
||||
.reduce((obj, role) => Object.assign(obj, { [role]: true }), {}); |
||||
if (roles.owner) { |
||||
roles.creator = true; |
||||
} |
||||
done(null, { |
||||
isAuthorized, |
||||
isPublicBase, |
||||
id, |
||||
email, |
||||
email_verified, |
||||
provider, |
||||
firstname, |
||||
lastname, |
||||
roles, |
||||
token_version, |
||||
}); |
||||
}); |
||||
|
||||
passport.deserializeUser(function (user, done) { |
||||
done(null, user); |
||||
}); |
||||
|
||||
passport.use( |
||||
new JwtStrategy( |
||||
{ |
||||
secretOrKey: Noco.getConfig().auth.jwt.secret, |
||||
...jwtOptions, |
||||
passReqToCallback: true, |
||||
...Noco.getConfig().auth.jwt.options, |
||||
}, |
||||
async (req, jwtPayload, done) => { |
||||
// todo: improve this
|
||||
if ( |
||||
req.ncProjectId && |
||||
jwtPayload.roles?.split(',').includes(OrgUserRoles.SUPER_ADMIN) |
||||
) { |
||||
return User.getByEmail(jwtPayload?.email).then(async (user) => { |
||||
return done(null, { |
||||
...user, |
||||
roles: `owner,creator,${OrgUserRoles.SUPER_ADMIN}`, |
||||
}); |
||||
}); |
||||
} |
||||
|
||||
const keyVals = [jwtPayload?.email]; |
||||
if (req.ncProjectId) { |
||||
keyVals.push(req.ncProjectId); |
||||
} |
||||
const key = keyVals.join('___'); |
||||
const cachedVal = await NocoCache.get( |
||||
`${CacheScope.USER}:${key}`, |
||||
CacheGetType.TYPE_OBJECT |
||||
); |
||||
|
||||
if (cachedVal) { |
||||
if ( |
||||
!cachedVal.token_version || |
||||
!jwtPayload.token_version || |
||||
cachedVal.token_version !== jwtPayload.token_version |
||||
) { |
||||
return done(new Error('Token Expired. Please login again.')); |
||||
} |
||||
return done(null, cachedVal); |
||||
} |
||||
|
||||
User.getByEmail(jwtPayload?.email) |
||||
.then(async (user) => { |
||||
if ( |
||||
!user.token_version || |
||||
!jwtPayload.token_version || |
||||
user.token_version !== jwtPayload.token_version |
||||
) { |
||||
return done(new Error('Token Expired. Please login again.')); |
||||
} |
||||
if (req.ncProjectId) { |
||||
// this.xcMeta
|
||||
// .metaGet(req.ncProjectId, null, 'nc_projects_users', {
|
||||
// user_id: user?.id
|
||||
// })
|
||||
|
||||
ProjectUser.get(req.ncProjectId, user.id) |
||||
.then(async (projectUser) => { |
||||
user.roles = projectUser?.roles || user.roles; |
||||
user.roles = |
||||
user.roles === 'owner' ? 'owner,creator' : user.roles; |
||||
// + (user.roles ? `,${user.roles}` : '');
|
||||
|
||||
await NocoCache.set(`${CacheScope.USER}:${key}`, user); |
||||
done(null, user); |
||||
}) |
||||
.catch((e) => done(e)); |
||||
} else { |
||||
// const roles = projectUser?.roles ? JSON.parse(projectUser.roles) : {guest: true};
|
||||
if (user) { |
||||
await NocoCache.set(`${CacheScope.USER}:${key}`, user); |
||||
return done(null, user); |
||||
} else { |
||||
return done(new Error('User not found')); |
||||
} |
||||
} |
||||
}) |
||||
.catch((err) => { |
||||
return done(err); |
||||
}); |
||||
} |
||||
) |
||||
); |
||||
|
||||
passport.use( |
||||
new PassportLocalStrategy( |
||||
{ |
||||
usernameField: 'email', |
||||
session: false, |
||||
}, |
||||
async (email, password, done) => { |
||||
try { |
||||
const user = await User.getByEmail(email); |
||||
if (!user) { |
||||
return done({ msg: `Email ${email} is not registered!` }); |
||||
} |
||||
|
||||
if (!user.salt) { |
||||
return done({ |
||||
msg: `Please sign up with the invite token first or reset the password by clicking Forgot your password.`, |
||||
}); |
||||
} |
||||
|
||||
const hashedPassword = await promisify(bcrypt.hash)( |
||||
password, |
||||
user.salt |
||||
); |
||||
if (user.password !== hashedPassword) { |
||||
return done({ msg: `Password not valid!` }); |
||||
} else { |
||||
return done(null, user); |
||||
} |
||||
} catch (e) { |
||||
done(e); |
||||
} |
||||
} |
||||
) |
||||
); |
||||
|
||||
passport.use( |
||||
'baseView', |
||||
new CustomStrategy(async (req: any, callback) => { |
||||
let user; |
||||
if (req.headers['xc-shared-base-id']) { |
||||
// const cacheKey = `nc_shared_bases||${req.headers['xc-shared-base-id']}`;
|
||||
|
||||
let sharedProject = null; |
||||
|
||||
if (!sharedProject) { |
||||
sharedProject = await Project.getByUuid( |
||||
req.headers['xc-shared-base-id'] |
||||
); |
||||
} |
||||
user = { |
||||
roles: sharedProject?.roles, |
||||
}; |
||||
} |
||||
|
||||
callback(null, user); |
||||
}) |
||||
); |
||||
|
||||
// mostly copied from older code
|
||||
Plugin.getPluginByTitle('Google').then((googlePlugin) => { |
||||
if (googlePlugin && googlePlugin.input) { |
||||
const settings = JSON.parse(googlePlugin.input); |
||||
process.env.NC_GOOGLE_CLIENT_ID = settings.client_id; |
||||
process.env.NC_GOOGLE_CLIENT_SECRET = settings.client_secret; |
||||
} |
||||
|
||||
if ( |
||||
process.env.NC_GOOGLE_CLIENT_ID && |
||||
process.env.NC_GOOGLE_CLIENT_SECRET |
||||
) { |
||||
const googleAuthParamsOrig = GoogleStrategy.prototype.authorizationParams; |
||||
GoogleStrategy.prototype.authorizationParams = (options: any) => { |
||||
const params = googleAuthParamsOrig.call(this, options); |
||||
|
||||
if (options.state) { |
||||
params.state = options.state; |
||||
} |
||||
|
||||
return params; |
||||
}; |
||||
|
||||
const clientConfig = { |
||||
clientID: process.env.NC_GOOGLE_CLIENT_ID, |
||||
clientSecret: process.env.NC_GOOGLE_CLIENT_SECRET, |
||||
// todo: update url
|
||||
callbackURL: 'http://localhost:3000', |
||||
passReqToCallback: true, |
||||
}; |
||||
|
||||
const googleStrategy = new GoogleStrategy( |
||||
clientConfig, |
||||
async (req, _accessToken, _refreshToken, profile, done) => { |
||||
const email = profile.emails[0].value; |
||||
|
||||
User.getByEmail(email) |
||||
.then(async (user) => { |
||||
if (user) { |
||||
// if project id defined extract project level roles
|
||||
if (req.ncProjectId) { |
||||
ProjectUser.get(req.ncProjectId, user.id) |
||||
.then(async (projectUser) => { |
||||
user.roles = projectUser?.roles || user.roles; |
||||
user.roles = |
||||
user.roles === 'owner' ? 'owner,creator' : user.roles; |
||||
// + (user.roles ? `,${user.roles}` : '');
|
||||
|
||||
done(null, user); |
||||
}) |
||||
.catch((e) => done(e)); |
||||
} else { |
||||
return done(null, user); |
||||
} |
||||
// if user not found create new user if allowed
|
||||
// or return error
|
||||
} else { |
||||
const salt = await promisify(bcrypt.genSalt)(10); |
||||
const user = await registerNewUserIfAllowed({ |
||||
firstname: null, |
||||
lastname: null, |
||||
email_verification_token: null, |
||||
email: profile.emails[0].value, |
||||
password: '', |
||||
salt, |
||||
}); |
||||
return done(null, user); |
||||
} |
||||
}) |
||||
.catch((err) => { |
||||
return done(err); |
||||
}); |
||||
} |
||||
); |
||||
|
||||
passport.use(googleStrategy); |
||||
} |
||||
}); |
||||
|
||||
router.use(passport.initialize()); |
||||
} |
@ -1,70 +0,0 @@
|
||||
export default `<!DOCTYPE html>
|
||||
<html> |
||||
<head> |
||||
<title>NocoDB - Verify Email</title> |
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, minimal-ui"> |
||||
<link href="<%- ncPublicUrl %>/css/fonts.roboto.css" rel="stylesheet"> |
||||
<link href="<%- ncPublicUrl %>/css/materialdesignicons.5.x.min.css" rel="stylesheet"> |
||||
<link href="<%- ncPublicUrl %>/css/vuetify.2.x.min.css" rel="stylesheet"> |
||||
<script src="<%- ncPublicUrl %>/js/vue.2.6.14.min.js"></script> |
||||
</head> |
||||
<body> |
||||
<div id="app"> |
||||
<v-app> |
||||
<v-container> |
||||
<v-row class="justify-center"> |
||||
<v-col class="col-12 col-md-6"> |
||||
<v-alert v-if="valid" type="success"> |
||||
Email verified successfully! |
||||
</v-alert> |
||||
<v-alert v-else-if="errMsg" type="error"> |
||||
{{errMsg}} |
||||
</v-alert> |
||||
|
||||
<template v-else> |
||||
|
||||
<v-skeleton-loader type="heading"></v-skeleton-loader> |
||||
|
||||
</template> |
||||
</v-col> |
||||
</v-row> |
||||
</v-container> |
||||
</v-app> |
||||
</div> |
||||
<script src="<%- ncPublicUrl %>/js/vuetify.2.x.min.js"></script> |
||||
<script src="<%- ncPublicUrl %>/js/axios.0.19.2.min.js"></script> |
||||
|
||||
<script> |
||||
var app = new Vue({ |
||||
el: '#app', |
||||
vuetify: new Vuetify(), |
||||
data: { |
||||
valid: null, |
||||
errMsg: null, |
||||
validForm: false, |
||||
token: <%- token %>, |
||||
greeting: 'Password Reset', |
||||
formdata: { |
||||
password: '', |
||||
newPassword: '' |
||||
}, |
||||
success: false |
||||
}, |
||||
methods: {}, |
||||
async created() { |
||||
try { |
||||
const valid = (await axios.post('<%- baseUrl %>/api/v1/auth/email/validate/' + this.token)).data; |
||||
this.valid = !!valid; |
||||
} catch (e) { |
||||
this.valid = false; |
||||
if(e.response && e.response.data && e.response.data.msg){ |
||||
this.errMsg = e.response.data.msg; |
||||
}else{ |
||||
this.errMsg = 'Some error occurred'; |
||||
} |
||||
} |
||||
} |
||||
}) |
||||
</script> |
||||
</body> |
||||
</html>`;
|
@ -1,108 +0,0 @@
|
||||
export default `<!DOCTYPE html>
|
||||
<html> |
||||
<head> |
||||
<title>NocoDB - Reset Password</title> |
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, minimal-ui"> |
||||
<link href="<%- ncPublicUrl %>/css/fonts.roboto.css" rel="stylesheet"> |
||||
<link href="<%- ncPublicUrl %>/css/materialdesignicons.5.x.min.css" rel="stylesheet"> |
||||
<link href="<%- ncPublicUrl %>/css/vuetify.2.x.min.css" rel="stylesheet"> |
||||
<script src="<%- ncPublicUrl %>/js/vue.2.6.14.min.js"></script> |
||||
</head> |
||||
<body> |
||||
<div id="app"> |
||||
<v-app> |
||||
<v-container> |
||||
<v-row class="justify-center"> |
||||
<v-col class="col-12 col-md-6"> |
||||
<v-alert v-if="success" type="success"> |
||||
Password reset successful! |
||||
</v-alert> |
||||
<template v-else> |
||||
|
||||
<v-form ref="form" v-model="validForm" v-if="valid === true" ref="formType" class="ma-auto" |
||||
lazy-validation> |
||||
|
||||
|
||||
<v-text-field |
||||
name="input-10-2" |
||||
label="New password" |
||||
type="password" |
||||
v-model="formdata.password" |
||||
:rules="[v => !!v || 'Password is required']" |
||||
></v-text-field> |
||||
|
||||
<v-text-field |
||||
name="input-10-2" |
||||
type="password" |
||||
label="Confirm new password" |
||||
v-model="formdata.newPassword" |
||||
:rules="[v => !!v || 'Password is required', v => v === formdata.password || 'Password mismatch']" |
||||
></v-text-field> |
||||
|
||||
<v-btn |
||||
:disabled="!validForm" |
||||
large |
||||
@click="resetPassword" |
||||
> |
||||
RESET PASSWORD |
||||
</v-btn> |
||||
|
||||
</v-form> |
||||
<div v-else-if="valid === false">Not a valid url</div> |
||||
<div v-else> |
||||
<v-skeleton-loader type="actions"></v-skeleton-loader> |
||||
</div> |
||||
</template> |
||||
</v-col> |
||||
</v-row> |
||||
</v-container> |
||||
</v-app> |
||||
</div> |
||||
<script src="<%- ncPublicUrl %>/js/vuetify.2.x.min.js"></script> |
||||
<script src="<%- ncPublicUrl %>/js/axios.0.19.2.min.js"></script> |
||||
|
||||
<script> |
||||
var app = new Vue({ |
||||
el: '#app', |
||||
vuetify: new Vuetify(), |
||||
data: { |
||||
valid: null, |
||||
validForm: false, |
||||
token: <%- token %>, |
||||
greeting: 'Password Reset', |
||||
formdata: { |
||||
password: '', |
||||
newPassword: '' |
||||
}, |
||||
success: false |
||||
}, |
||||
methods: { |
||||
async resetPassword() { |
||||
if (this.$refs.form.validate()) { |
||||
try { |
||||
const res = await axios.post('<%- baseUrl %>api/v1/db/auth/password/reset/' + this.token, { |
||||
...this.formdata |
||||
}); |
||||
this.success = true; |
||||
} catch (e) { |
||||
if (e.response && e.response.data && e.response.data.msg) { |
||||
alert('Failed to reset password: ' + e.response.data.msg) |
||||
} else { |
||||
alert('Some error occurred') |
||||
} |
||||
} |
||||
} |
||||
} |
||||
}, |
||||
async created() { |
||||
try { |
||||
const valid = (await axios.post('<%- baseUrl %>api/v1/db/auth/token/validate/' + this.token)).data; |
||||
this.valid = !!valid; |
||||
} catch (e) { |
||||
this.valid = false; |
||||
} |
||||
} |
||||
}) |
||||
</script> |
||||
</body> |
||||
</html>`;
|
@ -1,171 +0,0 @@
|
||||
export default `<!doctype html>
|
||||
<html> |
||||
<head> |
||||
<meta name="viewport" content="width=device-width"> |
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"> |
||||
<title>Simple Transactional Email</title> |
||||
<style> |
||||
@media only screen and (max-width: 620px) { |
||||
table[class=body] h1 { |
||||
font-size: 28px !important; |
||||
margin-bottom: 10px !important; |
||||
} |
||||
|
||||
table[class=body] p, |
||||
table[class=body] ul, |
||||
table[class=body] ol, |
||||
table[class=body] td, |
||||
table[class=body] span, |
||||
table[class=body] a { |
||||
font-size: 16px !important; |
||||
} |
||||
|
||||
table[class=body] .wrapper, |
||||
table[class=body] .article { |
||||
padding: 10px !important; |
||||
} |
||||
|
||||
table[class=body] .content { |
||||
padding: 0 !important; |
||||
} |
||||
|
||||
table[class=body] .container { |
||||
padding: 0 !important; |
||||
width: 100% !important; |
||||
} |
||||
|
||||
table[class=body] .main { |
||||
border-left-width: 0 !important; |
||||
border-radius: 0 !important; |
||||
border-right-width: 0 !important; |
||||
} |
||||
|
||||
table[class=body] .btn table { |
||||
width: 100% !important; |
||||
} |
||||
|
||||
table[class=body] .btn a { |
||||
width: 100% !important; |
||||
} |
||||
|
||||
table[class=body] .img-responsive { |
||||
height: auto !important; |
||||
max-width: 100% !important; |
||||
width: auto !important; |
||||
} |
||||
} |
||||
@media all { |
||||
.ExternalClass { |
||||
width: 100%; |
||||
} |
||||
|
||||
.ExternalClass, |
||||
.ExternalClass p, |
||||
.ExternalClass span, |
||||
.ExternalClass font, |
||||
.ExternalClass td, |
||||
.ExternalClass div { |
||||
line-height: 100%; |
||||
} |
||||
|
||||
.apple-link a { |
||||
color: inherit !important; |
||||
font-family: inherit !important; |
||||
font-size: inherit !important; |
||||
font-weight: inherit !important; |
||||
line-height: inherit !important; |
||||
text-decoration: none !important; |
||||
} |
||||
|
||||
#MessageViewBody a { |
||||
color: inherit; |
||||
text-decoration: none; |
||||
font-size: inherit; |
||||
font-family: inherit; |
||||
font-weight: inherit; |
||||
line-height: inherit; |
||||
} |
||||
|
||||
.btn-primary table td:hover { |
||||
background-color: #34495e !important; |
||||
} |
||||
|
||||
.btn-primary a:hover { |
||||
background-color: #34495e !important; |
||||
border-color: #34495e !important; |
||||
} |
||||
} |
||||
</style> |
||||
</head> |
||||
<body class="" style="background-color: #f6f6f6; font-family: sans-serif; -webkit-font-smoothing: antialiased; font-size: 14px; line-height: 1.4; margin: 0; padding: 0; -ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%;"> |
||||
<span class="preheader" style="color: transparent; display: none; height: 0; max-height: 0; max-width: 0; opacity: 0; overflow: hidden; mso-hide: all; visibility: hidden; width: 0;">This is preheader text. Some clients will show this text as a preview.</span> |
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0" class="body" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; background-color: #f6f6f6; width: 100%;" width="100%" bgcolor="#f6f6f6"> |
||||
<tr> |
||||
<td style="font-family: sans-serif; font-size: 14px; vertical-align: top;" valign="top"> </td> |
||||
<td class="container" style="font-family: sans-serif; font-size: 14px; vertical-align: top; display: block; max-width: 580px; padding: 10px; width: 580px; margin: 0 auto;" width="580" valign="top"> |
||||
<div class="content" style="box-sizing: border-box; display: block; margin: 0 auto; max-width: 580px; padding: 10px;"> |
||||
|
||||
<!-- START CENTERED WHITE CONTAINER --> |
||||
<table role="presentation" class="main" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; background: #ffffff; border-radius: 3px; width: 100%;" width="100%"> |
||||
|
||||
<!-- START MAIN CONTENT AREA --> |
||||
<tr> |
||||
<td class="wrapper" style="font-family: sans-serif; font-size: 14px; vertical-align: top; box-sizing: border-box; padding: 20px;" valign="top"> |
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%;" width="100%"> |
||||
<tr> |
||||
<td style="font-family: sans-serif; font-size: 14px; vertical-align: top;" valign="top"> |
||||
<p style="font-family: sans-serif; font-size: 14px; font-weight: normal; margin: 0; margin-bottom: 15px;">Hi,</p> |
||||
<p style="font-family: sans-serif; font-size: 14px; font-weight: normal; margin: 0; margin-bottom: 15px;">To change your NocoDB account password click the following link.</p> |
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0" class="btn btn-primary" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; box-sizing: border-box; width: 100%;" width="100%"> |
||||
<tbody> |
||||
<tr> |
||||
<td align="left" style="font-family: sans-serif; font-size: 14px; vertical-align: top; padding-bottom: 15px;" valign="top"> |
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: auto;"> |
||||
<tbody> |
||||
<tr> |
||||
<td style="font-family: sans-serif; font-size: 14px; vertical-align: top; border-radius: 5px; text-align: center; background-color: #3498db;" valign="top" align="center" bgcolor="#1088ff"> <a href="<%- resetLink %>" target="_blank" style="border: solid 1px rgb(23, 139, 255); border-radius: 5px; box-sizing: border-box; cursor: pointer; display: inline-block; font-size: 14px; font-weight: bold; margin: 0; padding: 12px 25px; text-decoration: none; text-transform: capitalize; background-color: rgb(23, 139, 255); border-color: #3498db; color: #ffffff;">Reset Password</a> </td> |
||||
</tr> |
||||
</tbody> |
||||
</table> |
||||
</td> |
||||
</tr> |
||||
</tbody> |
||||
</table> |
||||
<p style="font-family: sans-serif; font-size: 14px; font-weight: normal; margin: 0; margin-bottom: 15px;">Thanks regards NocoDB.</p> |
||||
</td> |
||||
</tr> |
||||
</table> |
||||
</td> |
||||
</tr> |
||||
|
||||
<!-- END MAIN CONTENT AREA --> |
||||
</table> |
||||
<!-- END CENTERED WHITE CONTAINER --> |
||||
|
||||
<!-- START FOOTER --> |
||||
<div class="footer" style="clear: both; margin-top: 10px; text-align: center; width: 100%;"> |
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%;" width="100%"> |
||||
<tr> |
||||
<td class="content-block" style="font-family: sans-serif; vertical-align: top; padding-bottom: 10px; padding-top: 10px; color: #999999; font-size: 12px; text-align: center;" valign="top" align="center"> |
||||
<span class="apple-link" style="color: #999999; font-size: 12px; text-align: center;"></span> |
||||
<!-- <br> Don't like these emails? <a href="http://i.imgur.com/CScmqnj.gif">Unsubscribe</a>.--> |
||||
</td> |
||||
</tr> |
||||
<tr> |
||||
<td class="content-block powered-by" style="font-family: sans-serif; vertical-align: top; padding-bottom: 10px; padding-top: 10px; color: #999999; font-size: 12px; text-align: center;" valign="top" align="center"> |
||||
<a href="http://nocodb.com/">NocoDB</a> |
||||
<!-- Powered by <a href="http://htmlemail.io">HTMLemail</a>.--> |
||||
</td> |
||||
</tr> |
||||
</table> |
||||
</div> |
||||
<!-- END FOOTER --> |
||||
|
||||
</div> |
||||
</td> |
||||
<td style="font-family: sans-serif; font-size: 14px; vertical-align: top;" valign="top"> </td> |
||||
</tr> |
||||
</table> |
||||
</body> |
||||
</html> |
||||
`;
|
@ -1,208 +0,0 @@
|
||||
export default `<!doctype html>
|
||||
<html> |
||||
<head> |
||||
<meta name="viewport" content="width=device-width"> |
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"> |
||||
<title>Simple Transactional Email</title> |
||||
<style> |
||||
@media only screen and (max-width: 620px) { |
||||
table[class=body] h1 { |
||||
font-size: 28px !important; |
||||
margin-bottom: 10px !important; |
||||
} |
||||
|
||||
table[class=body] p, |
||||
table[class=body] ul, |
||||
table[class=body] ol, |
||||
table[class=body] td, |
||||
table[class=body] span, |
||||
table[class=body] a { |
||||
font-size: 16px !important; |
||||
} |
||||
|
||||
table[class=body] .wrapper, |
||||
table[class=body] .article { |
||||
padding: 10px !important; |
||||
} |
||||
|
||||
table[class=body] .content { |
||||
padding: 0 !important; |
||||
} |
||||
|
||||
table[class=body] .container { |
||||
padding: 0 !important; |
||||
width: 100% !important; |
||||
} |
||||
|
||||
table[class=body] .main { |
||||
border-left-width: 0 !important; |
||||
border-radius: 0 !important; |
||||
border-right-width: 0 !important; |
||||
} |
||||
|
||||
table[class=body] .btn table { |
||||
width: 100% !important; |
||||
} |
||||
|
||||
table[class=body] .btn a { |
||||
width: 100% !important; |
||||
} |
||||
|
||||
table[class=body] .img-responsive { |
||||
height: auto !important; |
||||
max-width: 100% !important; |
||||
width: auto !important; |
||||
} |
||||
} |
||||
|
||||
@media all { |
||||
.ExternalClass { |
||||
width: 100%; |
||||
} |
||||
|
||||
.ExternalClass, |
||||
.ExternalClass p, |
||||
.ExternalClass span, |
||||
.ExternalClass font, |
||||
.ExternalClass td, |
||||
.ExternalClass div { |
||||
line-height: 100%; |
||||
} |
||||
|
||||
.apple-link a { |
||||
color: inherit !important; |
||||
font-family: inherit !important; |
||||
font-size: inherit !important; |
||||
font-weight: inherit !important; |
||||
line-height: inherit !important; |
||||
text-decoration: none !important; |
||||
} |
||||
|
||||
#MessageViewBody a { |
||||
color: inherit; |
||||
text-decoration: none; |
||||
font-size: inherit; |
||||
font-family: inherit; |
||||
font-weight: inherit; |
||||
line-height: inherit; |
||||
} |
||||
|
||||
.btn-primary table td:hover { |
||||
background-color: #34495e !important; |
||||
} |
||||
|
||||
.btn-primary a:hover { |
||||
background-color: #34495e !important; |
||||
border-color: #34495e !important; |
||||
} |
||||
} |
||||
</style> |
||||
</head> |
||||
<body class="" |
||||
style="background-color: #f6f6f6; font-family: sans-serif; -webkit-font-smoothing: antialiased; font-size: 14px; line-height: 1.4; margin: 0; padding: 0; -ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%;"> |
||||
<span class="preheader" |
||||
style="color: transparent; display: none; height: 0; max-height: 0; max-width: 0; opacity: 0; overflow: hidden; mso-hide: all; visibility: hidden; width: 0;">This is preheader text. Some clients will show this text as a preview.</span> |
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0" class="body" |
||||
style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; background-color: #f6f6f6; width: 100%;" |
||||
width="100%" bgcolor="#f6f6f6"> |
||||
<tr> |
||||
<td style="font-family: sans-serif; font-size: 14px; vertical-align: top;" valign="top"> </td> |
||||
<td class="container" |
||||
style="font-family: sans-serif; font-size: 14px; vertical-align: top; display: block; max-width: 580px; padding: 10px; width: 580px; margin: 0 auto;" |
||||
width="580" valign="top"> |
||||
<div class="content" |
||||
style="box-sizing: border-box; display: block; margin: 0 auto; max-width: 580px; padding: 10px;"> |
||||
|
||||
<!-- START CENTERED WHITE CONTAINER --> |
||||
<table role="presentation" class="main" |
||||
style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; background: #ffffff; border-radius: 3px; width: 100%;" |
||||
width="100%"> |
||||
|
||||
<!-- START MAIN CONTENT AREA --> |
||||
<tr> |
||||
<td class="wrapper" |
||||
style="font-family: sans-serif; font-size: 14px; vertical-align: top; box-sizing: border-box; padding: 20px;" |
||||
valign="top"> |
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0" |
||||
style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%;" |
||||
width="100%"> |
||||
<tr> |
||||
<td style="font-family: sans-serif; font-size: 14px; vertical-align: top;" |
||||
valign="top"> |
||||
<p style="font-family: sans-serif; font-size: 14px; font-weight: normal; margin: 0; margin-bottom: 15px;"> |
||||
Hi,</p> |
||||
<p style="font-family: sans-serif; font-size: 14px; font-weight: normal; margin: 0; margin-bottom: 15px;"> |
||||
I invited you to be "<%- roles -%>" of the NocoDB project "<%- projectName %>". |
||||
Click the button below to to accept my invitation.</p> |
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0" |
||||
class="btn btn-primary" |
||||
style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; box-sizing: border-box; width: 100%;" |
||||
width="100%"> |
||||
<tbody> |
||||
<tr> |
||||
<td align="left" |
||||
style="font-family: sans-serif; font-size: 14px; vertical-align: top; padding-bottom: 15px;" |
||||
valign="top"> |
||||
<table role="presentation" border="0" cellpadding="0" |
||||
cellspacing="0" |
||||
style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: auto;"> |
||||
<tbody> |
||||
<tr> |
||||
<td style="font-family: sans-serif; font-size: 14px; vertical-align: top; border-radius: 5px; text-align: center; background-color: #3498db;" |
||||
valign="top" align="center" bgcolor="#1088ff"><a |
||||
href="<%- signupLink %>" target="_blank" |
||||
style="border: solid 1px rgb(23, 139, 255); border-radius: 5px; box-sizing: border-box; cursor: pointer; display: inline-block; font-size: 14px; font-weight: bold; margin: 0; padding: 12px 25px; text-decoration: none; text-transform: capitalize; background-color: rgb(23, 139, 255); border-color: #3498db; color: #ffffff;">Signup</a> |
||||
</td> |
||||
</tr> |
||||
</tbody> |
||||
</table> |
||||
</td> |
||||
</tr> |
||||
</tbody> |
||||
</table> |
||||
<p style="font-family: sans-serif; font-size: 14px; font-weight: normal; margin: 0; margin-bottom: 15px;"> |
||||
Thanks regards <%- adminEmail %>.</p> |
||||
</td> |
||||
</tr> |
||||
</table> |
||||
</td> |
||||
</tr> |
||||
|
||||
<!-- END MAIN CONTENT AREA --> |
||||
</table> |
||||
<!-- END CENTERED WHITE CONTAINER --> |
||||
|
||||
<!-- START FOOTER --> |
||||
<div class="footer" style="clear: both; margin-top: 10px; text-align: center; width: 100%;"> |
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0" |
||||
style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%;" |
||||
width="100%"> |
||||
<tr> |
||||
<td class="content-block" |
||||
style="font-family: sans-serif; vertical-align: top; padding-bottom: 10px; padding-top: 10px; color: #999999; font-size: 12px; text-align: center;" |
||||
valign="top" align="center"> |
||||
<span class="apple-link" |
||||
style="color: #999999; font-size: 12px; text-align: center;"></span> |
||||
<!-- <br> Don't like these emails? <a href="http://i.imgur.com/CScmqnj.gif">Unsubscribe</a>.--> |
||||
</td> |
||||
</tr> |
||||
<tr> |
||||
<td class="content-block powered-by" |
||||
style="font-family: sans-serif; vertical-align: top; padding-bottom: 10px; padding-top: 10px; color: #999999; font-size: 12px; text-align: center;" |
||||
valign="top" align="center"> |
||||
<a href="http://nocodb.com/">NocoDB</a> |
||||
<!-- Powered by <a href="http://htmlemail.io">HTMLemail</a>.--> |
||||
</td> |
||||
</tr> |
||||
</table> |
||||
</div> |
||||
<!-- END FOOTER --> |
||||
|
||||
</div> |
||||
</td> |
||||
<td style="font-family: sans-serif; font-size: 14px; vertical-align: top;" valign="top"> </td> |
||||
</tr> |
||||
</table> |
||||
</body> |
||||
</html> |
||||
`;
|
@ -1,207 +0,0 @@
|
||||
export default `<!doctype html>
|
||||
<html> |
||||
<head> |
||||
<meta name="viewport" content="width=device-width"> |
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"> |
||||
<title>Simple Transactional Email</title> |
||||
<style> |
||||
@media only screen and (max-width: 620px) { |
||||
table[class=body] h1 { |
||||
font-size: 28px !important; |
||||
margin-bottom: 10px !important; |
||||
} |
||||
|
||||
table[class=body] p, |
||||
table[class=body] ul, |
||||
table[class=body] ol, |
||||
table[class=body] td, |
||||
table[class=body] span, |
||||
table[class=body] a { |
||||
font-size: 16px !important; |
||||
} |
||||
|
||||
table[class=body] .wrapper, |
||||
table[class=body] .article { |
||||
padding: 10px !important; |
||||
} |
||||
|
||||
table[class=body] .content { |
||||
padding: 0 !important; |
||||
} |
||||
|
||||
table[class=body] .container { |
||||
padding: 0 !important; |
||||
width: 100% !important; |
||||
} |
||||
|
||||
table[class=body] .main { |
||||
border-left-width: 0 !important; |
||||
border-radius: 0 !important; |
||||
border-right-width: 0 !important; |
||||
} |
||||
|
||||
table[class=body] .btn table { |
||||
width: 100% !important; |
||||
} |
||||
|
||||
table[class=body] .btn a { |
||||
width: 100% !important; |
||||
} |
||||
|
||||
table[class=body] .img-responsive { |
||||
height: auto !important; |
||||
max-width: 100% !important; |
||||
width: auto !important; |
||||
} |
||||
} |
||||
|
||||
@media all { |
||||
.ExternalClass { |
||||
width: 100%; |
||||
} |
||||
|
||||
.ExternalClass, |
||||
.ExternalClass p, |
||||
.ExternalClass span, |
||||
.ExternalClass font, |
||||
.ExternalClass td, |
||||
.ExternalClass div { |
||||
line-height: 100%; |
||||
} |
||||
|
||||
.apple-link a { |
||||
color: inherit !important; |
||||
font-family: inherit !important; |
||||
font-size: inherit !important; |
||||
font-weight: inherit !important; |
||||
line-height: inherit !important; |
||||
text-decoration: none !important; |
||||
} |
||||
|
||||
#MessageViewBody a { |
||||
color: inherit; |
||||
text-decoration: none; |
||||
font-size: inherit; |
||||
font-family: inherit; |
||||
font-weight: inherit; |
||||
line-height: inherit; |
||||
} |
||||
|
||||
.btn-primary table td:hover { |
||||
background-color: #34495e !important; |
||||
} |
||||
|
||||
.btn-primary a:hover { |
||||
background-color: #34495e !important; |
||||
border-color: #34495e !important; |
||||
} |
||||
} |
||||
</style> |
||||
</head> |
||||
<body class="" |
||||
style="background-color: #f6f6f6; font-family: sans-serif; -webkit-font-smoothing: antialiased; font-size: 14px; line-height: 1.4; margin: 0; padding: 0; -ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%;"> |
||||
<span class="preheader" |
||||
style="color: transparent; display: none; height: 0; max-height: 0; max-width: 0; opacity: 0; overflow: hidden; mso-hide: all; visibility: hidden; width: 0;">This is preheader text. Some clients will show this text as a preview.</span> |
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0" class="body" |
||||
style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; background-color: #f6f6f6; width: 100%;" |
||||
width="100%" bgcolor="#f6f6f6"> |
||||
<tr> |
||||
<td style="font-family: sans-serif; font-size: 14px; vertical-align: top;" valign="top"> </td> |
||||
<td class="container" |
||||
style="font-family: sans-serif; font-size: 14px; vertical-align: top; display: block; max-width: 580px; padding: 10px; width: 580px; margin: 0 auto;" |
||||
width="580" valign="top"> |
||||
<div class="content" |
||||
style="box-sizing: border-box; display: block; margin: 0 auto; max-width: 580px; padding: 10px;"> |
||||
|
||||
<!-- START CENTERED WHITE CONTAINER --> |
||||
<table role="presentation" class="main" |
||||
style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; background: #ffffff; border-radius: 3px; width: 100%;" |
||||
width="100%"> |
||||
|
||||
<!-- START MAIN CONTENT AREA --> |
||||
<tr> |
||||
<td class="wrapper" |
||||
style="font-family: sans-serif; font-size: 14px; vertical-align: top; box-sizing: border-box; padding: 20px;" |
||||
valign="top"> |
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0" |
||||
style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%;" |
||||
width="100%"> |
||||
<tr> |
||||
<td style="font-family: sans-serif; font-size: 14px; vertical-align: top;" |
||||
valign="top"> |
||||
<p style="font-family: sans-serif; font-size: 14px; font-weight: normal; margin: 0; margin-bottom: 15px;"> |
||||
Hi,</p> |
||||
<p style="font-family: sans-serif; font-size: 14px; font-weight: normal; margin: 0; margin-bottom: 15px;"> |
||||
Please verify your email address by clicking the following button.</p> |
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0" |
||||
class="btn btn-primary" |
||||
style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; box-sizing: border-box; width: 100%;" |
||||
width="100%"> |
||||
<tbody> |
||||
<tr> |
||||
<td align="left" |
||||
style="font-family: sans-serif; font-size: 14px; vertical-align: top; padding-bottom: 15px;" |
||||
valign="top"> |
||||
<table role="presentation" border="0" cellpadding="0" |
||||
cellspacing="0" |
||||
style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: auto;"> |
||||
<tbody> |
||||
<tr> |
||||
<td style="font-family: sans-serif; font-size: 14px; vertical-align: top; border-radius: 5px; text-align: center; background-color: #3498db;" |
||||
valign="top" align="center" bgcolor="#1088ff"><a |
||||
href="<%- verifyLink %>" target="_blank" |
||||
style="border: solid 1px rgb(23, 139, 255); border-radius: 5px; box-sizing: border-box; cursor: pointer; display: inline-block; font-size: 14px; font-weight: bold; margin: 0; padding: 12px 25px; text-decoration: none; text-transform: capitalize; background-color: rgb(23, 139, 255); border-color: #3498db; color: #ffffff;">Verify</a> |
||||
</td> |
||||
</tr> |
||||
</tbody> |
||||
</table> |
||||
</td> |
||||
</tr> |
||||
</tbody> |
||||
</table> |
||||
<p style="font-family: sans-serif; font-size: 14px; font-weight: normal; margin: 0; margin-bottom: 15px;"> |
||||
Thanks regards NocoDB.</p> |
||||
</td> |
||||
</tr> |
||||
</table> |
||||
</td> |
||||
</tr> |
||||
|
||||
<!-- END MAIN CONTENT AREA --> |
||||
</table> |
||||
<!-- END CENTERED WHITE CONTAINER --> |
||||
|
||||
<!-- START FOOTER --> |
||||
<div class="footer" style="clear: both; margin-top: 10px; text-align: center; width: 100%;"> |
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0" |
||||
style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%;" |
||||
width="100%"> |
||||
<tr> |
||||
<td class="content-block" |
||||
style="font-family: sans-serif; vertical-align: top; padding-bottom: 10px; padding-top: 10px; color: #999999; font-size: 12px; text-align: center;" |
||||
valign="top" align="center"> |
||||
<span class="apple-link" |
||||
style="color: #999999; font-size: 12px; text-align: center;"></span> |
||||
<!-- <br> Don't like these emails? <a href="http://i.imgur.com/CScmqnj.gif">Unsubscribe</a>.--> |
||||
</td> |
||||
</tr> |
||||
<tr> |
||||
<td class="content-block powered-by" |
||||
style="font-family: sans-serif; vertical-align: top; padding-bottom: 10px; padding-top: 10px; color: #999999; font-size: 12px; text-align: center;" |
||||
valign="top" align="center"> |
||||
<a href="http://nocodb.com/">NocoDB</a> |
||||
<!-- Powered by <a href="http://htmlemail.io">HTMLemail</a>.--> |
||||
</td> |
||||
</tr> |
||||
</table> |
||||
</div> |
||||
<!-- END FOOTER --> |
||||
|
||||
</div> |
||||
</td> |
||||
<td style="font-family: sans-serif; font-size: 14px; vertical-align: top;" valign="top"> </td> |
||||
</tr> |
||||
</table> |
||||
</body> |
||||
</html> |
||||
`;
|
Loading…
Reference in new issue