mirror of https://github.com/Budibase/budibase.git
48 changed files with 2438 additions and 732 deletions
@ -0,0 +1,61 @@ |
|||
#!/usr/bin/env node
|
|||
|
|||
const os = require("os") |
|||
const exec = require("child_process").exec |
|||
const fs = require("fs") |
|||
const platform = os.platform() |
|||
|
|||
const windows = platform === "win32" |
|||
const mac = platform === "darwin" |
|||
const linux = platform === "linux" |
|||
|
|||
function execute(command) { |
|||
return new Promise(resolve => { |
|||
exec(command, (err, stdout) => resolve(linux ? !!stdout : true)) |
|||
}) |
|||
} |
|||
|
|||
async function commandExistsUnix(command) { |
|||
const unixCmd = `command -v ${command} 2>/dev/null && { echo >&1 ${command}; exit 0; }` |
|||
return execute(command) |
|||
} |
|||
|
|||
async function commandExistsWindows(command) { |
|||
if (/[\x00-\x1f<>:"|?*]/.test(command)) { |
|||
return false |
|||
} |
|||
return execute(`where ${command}`) |
|||
} |
|||
|
|||
function commandExists(command) { |
|||
return windows ? commandExistsWindows(command) : commandExistsUnix(command) |
|||
} |
|||
|
|||
async function init() { |
|||
const docker = commandExists("docker") |
|||
const dockerCompose = commandExists("docker-compose") |
|||
if (docker && dockerCompose) { |
|||
console.log("Docker installed - continuing.") |
|||
return |
|||
} |
|||
if (mac) { |
|||
console.log( |
|||
"Please install docker by visiting: https://docs.docker.com/docker-for-mac/install/" |
|||
) |
|||
} else if (windows) { |
|||
console.log( |
|||
"Please install docker by visiting: https://docs.docker.com/docker-for-windows/install/" |
|||
) |
|||
} else if (linux) { |
|||
console.log("Beginning automated linux installation.") |
|||
await execute(`./hosting/scripts/linux/get-docker.sh`) |
|||
await execute(`./hosting/scripts/linux/get-docker-compose.sh`) |
|||
} else { |
|||
console.error( |
|||
"Platform unknown - please look online for information about installing docker for our OS." |
|||
) |
|||
} |
|||
console.log("Once installation complete please re-run the setup script.") |
|||
process.exit(-1) |
|||
} |
|||
init() |
|||
@ -1,25 +0,0 @@ |
|||
<script> |
|||
import { onMount } from "svelte" |
|||
import AppCard from "./AppCard.svelte" |
|||
import { apps } from "stores/portal" |
|||
|
|||
onMount(apps.load) |
|||
</script> |
|||
|
|||
{#if $apps.length} |
|||
<div class="appList"> |
|||
{#each $apps as app} |
|||
<AppCard {...app} /> |
|||
{/each} |
|||
</div> |
|||
{:else} |
|||
<div>No apps found.</div> |
|||
{/if} |
|||
|
|||
<style> |
|||
.appList { |
|||
display: grid; |
|||
grid-gap: 50px; |
|||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); |
|||
} |
|||
</style> |
|||
@ -0,0 +1,80 @@ |
|||
<script> |
|||
import { gradient } from "actions" |
|||
import { |
|||
Heading, |
|||
Button, |
|||
Icon, |
|||
ActionMenu, |
|||
MenuItem, |
|||
Link, |
|||
} from "@budibase/bbui" |
|||
import { url } from "@roxi/routify" |
|||
|
|||
export let app |
|||
export let openApp |
|||
export let exportApp |
|||
export let deleteApp |
|||
export let last |
|||
</script> |
|||
|
|||
<div class="title" class:last> |
|||
<div class="preview" use:gradient={{ seed: app.name }} /> |
|||
<Link href={$url(`../../app/${app._id}`)}> |
|||
<Heading size="XS"> |
|||
{app.name} |
|||
</Heading> |
|||
</Link> |
|||
</div> |
|||
<div class:last> |
|||
Edited {Math.round(Math.random() * 10 + 1)} months ago |
|||
</div> |
|||
<div class:last> |
|||
{#if Math.random() < 0.33} |
|||
<div class="status status--open" /> |
|||
Open |
|||
{:else if Math.random() < 0.33} |
|||
<div class="status status--locked-other" /> |
|||
Locked by Will Wheaton |
|||
{:else} |
|||
<div class="status status--locked-you" /> |
|||
Locked by you |
|||
{/if} |
|||
</div> |
|||
<div class:last> |
|||
<Button on:click={() => openApp(app)} size="S" secondary>Open</Button> |
|||
<ActionMenu align="right"> |
|||
<Icon hoverable slot="control" name="More" /> |
|||
<MenuItem on:click={() => exportApp(app)} icon="Download">Export</MenuItem> |
|||
<MenuItem on:click={() => deleteApp(app)} icon="Delete">Delete</MenuItem> |
|||
</ActionMenu> |
|||
</div> |
|||
|
|||
<style> |
|||
.preview { |
|||
height: 40px; |
|||
width: 40px; |
|||
border-radius: var(--border-radius-s); |
|||
} |
|||
.title :global(a) { |
|||
text-decoration: none; |
|||
} |
|||
.title :global(h1:hover) { |
|||
color: var(--spectrum-global-color-blue-600); |
|||
cursor: pointer; |
|||
transition: color 130ms ease; |
|||
} |
|||
.status { |
|||
height: 10px; |
|||
width: 10px; |
|||
border-radius: 50%; |
|||
} |
|||
.status--locked-you { |
|||
background-color: var(--spectrum-global-color-orange-600); |
|||
} |
|||
.status--locked-other { |
|||
background-color: var(--spectrum-global-color-red-600); |
|||
} |
|||
.status--open { |
|||
background-color: var(--spectrum-global-color-green-600); |
|||
} |
|||
</style> |
|||
@ -1,83 +0,0 @@ |
|||
<script> |
|||
export let step, done, active |
|||
</script> |
|||
|
|||
<div class="container" class:active class:done> |
|||
<div class="circle" class:active class:done> |
|||
{#if done} |
|||
<svg |
|||
width="12" |
|||
height="10" |
|||
viewBox="0 0 12 10" |
|||
fill="none" |
|||
xmlns="http://www.w3.org/2000/svg" |
|||
> |
|||
<path |
|||
fill-rule="evenodd" |
|||
clip-rule="evenodd" |
|||
d="M10.1212 0.319527C10.327 0.115582 10.6047 0.000803464 10.8944 |
|||
4.20219e-06C11.1841 -0.00079506 11.4624 0.11245 11.6693 |
|||
0.315256C11.8762 0.518062 11.9949 0.794134 11.9998 1.08379C12.0048 |
|||
1.37344 11.8955 1.65339 11.6957 1.86313L5.82705 9.19893C5.72619 |
|||
9.30757 5.60445 9.39475 5.46913 9.45527C5.3338 9.51578 5.18766 9.54839 |
|||
5.03944 9.55113C4.89123 9.55388 4.74398 9.52671 4.60651 |
|||
9.47124C4.46903 9.41578 4.34416 9.33316 4.23934 9.22833L0.350925 |
|||
5.33845C0.242598 5.23751 0.155712 5.11578 0.0954499 4.98054C0.0351876 |
|||
4.84529 0.00278364 4.69929 0.00017159 4.55124C-0.00244046 4.4032 |
|||
0.024793 4.25615 0.0802466 4.11886C0.1357 3.98157 0.218238 3.85685 |
|||
0.322937 3.75215C0.427636 3.64746 0.55235 3.56492 0.68964 |
|||
3.50946C0.82693 3.45401 0.973983 3.42678 1.12203 3.42939C1.27007 3.432 |
|||
1.41607 3.46441 1.55132 3.52467C1.68657 3.58493 1.80829 3.67182 |
|||
1.90923 3.78014L4.98762 6.85706L10.0933 0.35187C10.1024 0.340482 |
|||
10.1122 0.329679 10.1227 0.319527H10.1212Z" |
|||
fill="var(--background)" |
|||
/> |
|||
</svg> |
|||
{:else}{step}{/if} |
|||
</div> |
|||
</div> |
|||
|
|||
<style> |
|||
.container::before { |
|||
content: ""; |
|||
position: absolute; |
|||
top: -30px; |
|||
width: 1px; |
|||
height: 30px; |
|||
background: var(--grey-5); |
|||
} |
|||
.container:first-child::before { |
|||
display: none; |
|||
} |
|||
.container { |
|||
position: relative; |
|||
height: 45px; |
|||
display: grid; |
|||
place-items: center; |
|||
} |
|||
.container.active { |
|||
box-shadow: inset 3px 0 0 0 var(--blue); |
|||
} |
|||
.circle.active { |
|||
background: var(--blue); |
|||
color: white; |
|||
border: none; |
|||
} |
|||
.circle.done { |
|||
background: var(--grey-5); |
|||
color: white; |
|||
border: none; |
|||
} |
|||
.circle { |
|||
color: var(--grey-5); |
|||
font-size: 14px; |
|||
font-weight: 500; |
|||
display: grid; |
|||
place-items: center; |
|||
width: 30px; |
|||
height: 30px; |
|||
border-radius: 50%; |
|||
border: 1px solid var(--grey-5); |
|||
box-sizing: border-box; |
|||
} |
|||
</style> |
|||
@ -1,121 +0,0 @@ |
|||
<script> |
|||
import { Label, Heading, Input, notifications } from "@budibase/bbui" |
|||
|
|||
const BYTES_IN_MB = 1000000 |
|||
const FILE_SIZE_LIMIT = BYTES_IN_MB * 5 |
|||
|
|||
export let template |
|||
export let values |
|||
export let errors |
|||
export let touched |
|||
|
|||
let blurred = { appName: false } |
|||
let file |
|||
|
|||
function handleFile(evt) { |
|||
const fileArray = Array.from(evt.target.files) |
|||
if (fileArray.some(file => file.size >= FILE_SIZE_LIMIT)) { |
|||
notifications.error( |
|||
`Files cannot exceed ${ |
|||
FILE_SIZE_LIMIT / BYTES_IN_MB |
|||
}MB. Please try again with smaller files.` |
|||
) |
|||
return |
|||
} |
|||
file = evt.target.files[0] |
|||
template.file = file |
|||
} |
|||
</script> |
|||
|
|||
<div class="container"> |
|||
{#if template?.fromFile} |
|||
<Heading size="L">Import your Web App</Heading> |
|||
{:else} |
|||
<Heading size="L">Create your Web App</Heading> |
|||
{/if} |
|||
{#if template?.fromFile} |
|||
<div class="template"> |
|||
<Label extraSmall grey>Import File</Label> |
|||
<div class="dropzone"> |
|||
<input |
|||
id="file-upload" |
|||
accept=".txt" |
|||
type="file" |
|||
on:change={handleFile} |
|||
/> |
|||
<label for="file-upload" class:uploaded={file}> |
|||
{#if file}{file.name}{:else}Import{/if} |
|||
</label> |
|||
</div> |
|||
</div> |
|||
{:else if template} |
|||
<div class="template"> |
|||
<Label extraSmall grey>Selected Template</Label> |
|||
<Heading size="S">{template.name}</Heading> |
|||
</div> |
|||
{/if} |
|||
<Input |
|||
on:change={() => ($touched.applicationName = true)} |
|||
bind:value={$values.applicationName} |
|||
label="Web App Name" |
|||
placeholder="Enter name of your web application" |
|||
error={$touched.applicationName && $errors.applicationName} |
|||
/> |
|||
</div> |
|||
|
|||
<style> |
|||
.container { |
|||
display: grid; |
|||
grid-gap: var(--spacing-xl); |
|||
margin-top: var(--spacing-xl); |
|||
} |
|||
|
|||
.template :global(label) { |
|||
/* Fix layout due to LH 0 on heading */ |
|||
margin-bottom: 16px; |
|||
} |
|||
|
|||
.dropzone { |
|||
text-align: center; |
|||
display: flex; |
|||
align-items: center; |
|||
flex-direction: column; |
|||
border-radius: 10px; |
|||
transition: all 0.3s; |
|||
} |
|||
|
|||
.uploaded { |
|||
color: var(--blue); |
|||
} |
|||
|
|||
input[type="file"] { |
|||
display: none; |
|||
} |
|||
|
|||
label { |
|||
font-family: var(--font-sans); |
|||
cursor: pointer; |
|||
font-weight: 500; |
|||
box-sizing: border-box; |
|||
overflow: hidden; |
|||
border-radius: var(--border-radius-s); |
|||
color: var(--ink); |
|||
padding: var(--spacing-m) var(--spacing-l); |
|||
transition: all 0.2s ease 0s; |
|||
display: inline-flex; |
|||
text-rendering: optimizeLegibility; |
|||
min-width: auto; |
|||
outline: none; |
|||
font-feature-settings: "case" 1, "rlig" 1, "calt" 0; |
|||
-webkit-box-align: center; |
|||
user-select: none; |
|||
flex-shrink: 0; |
|||
align-items: center; |
|||
justify-content: center; |
|||
width: 100%; |
|||
background-color: var(--grey-2); |
|||
font-size: var(--font-size-xs); |
|||
line-height: normal; |
|||
border: var(--border-transparent); |
|||
} |
|||
</style> |
|||
@ -1,29 +0,0 @@ |
|||
<script> |
|||
import { Select, Heading } from "@budibase/bbui" |
|||
|
|||
export let values |
|||
export let errors |
|||
export let touched |
|||
</script> |
|||
|
|||
<div class="container"> |
|||
<Heading size="L">What's your role for this app?</Heading> |
|||
<Select |
|||
bind:value={$values.roleId} |
|||
label="Role" |
|||
options={[ |
|||
{ label: "Admin", value: "ADMIN" }, |
|||
{ label: "Power User", value: "POWER_USER" }, |
|||
]} |
|||
getOptionLabel={option => option.label} |
|||
getOptionValue={option => option.value} |
|||
error={$errors.roleId} |
|||
/> |
|||
</div> |
|||
|
|||
<style> |
|||
.container { |
|||
display: grid; |
|||
grid-gap: var(--spacing-xl); |
|||
} |
|||
</style> |
|||
@ -1,2 +0,0 @@ |
|||
export { default as Info } from "./Info.svelte" |
|||
export { default as User } from "./User.svelte" |
|||
@ -1,7 +0,0 @@ |
|||
<script> |
|||
import { Page } from "@budibase/bbui" |
|||
</script> |
|||
|
|||
<Page wide> |
|||
<slot /> |
|||
</Page> |
|||
@ -0,0 +1,15 @@ |
|||
<script> |
|||
import { Body, Menu, MenuItem, Detail, MenuSection, DetailSummary } from "@budibase/bbui" |
|||
|
|||
export let bindings |
|||
export let onBindingClick = () => {} |
|||
</script> |
|||
|
|||
<Menu> |
|||
{#each bindings as binding} |
|||
<MenuItem on:click={() => onBindingClick(binding)}> |
|||
<Detail size="M">{binding.name}</Detail> |
|||
<Body size="XS" noPadding>{binding.description}</Body> |
|||
</MenuItem> |
|||
{/each} |
|||
</Menu> |
|||
@ -0,0 +1,17 @@ |
|||
<script> |
|||
import { url } from "@roxi/routify" |
|||
import { |
|||
Link, |
|||
} from "@budibase/bbui" |
|||
import { roles } from "stores/backend" |
|||
|
|||
export let value |
|||
</script> |
|||
|
|||
<Link quiet href={$url(`./${value}`)}><span>{value}</span></Link> |
|||
|
|||
<style> |
|||
span { |
|||
text-transform: capitalize; |
|||
} |
|||
</style> |
|||
@ -0,0 +1,140 @@ |
|||
<script> |
|||
import { |
|||
Menu, |
|||
MenuItem, |
|||
Button, |
|||
Detail, |
|||
Heading, |
|||
Divider, |
|||
Label, |
|||
Modal, |
|||
ModalContent, |
|||
notifications, |
|||
Layout, |
|||
Icon, |
|||
Body, |
|||
Page, |
|||
Select, |
|||
Tabs, |
|||
Tab, |
|||
MenuSection, |
|||
MenuSeparator, |
|||
} from "@budibase/bbui" |
|||
import { goto } from "@roxi/routify" |
|||
import { onMount } from "svelte" |
|||
import { fade } from "svelte/transition" |
|||
import { email } from "stores/portal" |
|||
import Editor from "components/integration/QueryEditor.svelte" |
|||
import TemplateBindings from "./TemplateBindings.svelte" |
|||
import api from "builderStore/api" |
|||
|
|||
const ConfigTypes = { |
|||
SMTP: "smtp", |
|||
} |
|||
|
|||
export let template |
|||
|
|||
let selected = "Edit" |
|||
let selectedBindingTab = "Template" |
|||
let htmlEditor |
|||
|
|||
$: selectedTemplate = $email.templates.find( |
|||
({ purpose }) => purpose === template |
|||
) |
|||
$: templateBindings = |
|||
$email.definitions?.bindings[selectedTemplate.purpose] || [] |
|||
|
|||
async function saveTemplate() { |
|||
try { |
|||
// Save your template config |
|||
await email.templates.save(selectedTemplate) |
|||
notifications.success(`Template saved.`) |
|||
} catch (err) { |
|||
notifications.error(`Failed to update template settings. ${err}`) |
|||
} |
|||
} |
|||
|
|||
function setTemplateBinding(binding) { |
|||
htmlEditor.update((selectedTemplate.contents += `{{ ${binding.name} }}`)) |
|||
} |
|||
</script> |
|||
|
|||
<Page wide gap="L"> |
|||
<div class="backbutton" on:click={() => $goto("./")}> |
|||
<Icon name="BackAndroid" /> |
|||
<span>Back</span> |
|||
</div> |
|||
<header> |
|||
<Heading> |
|||
Email Template: {template} |
|||
</Heading> |
|||
<Button cta on:click={saveTemplate}>Save</Button> |
|||
</header> |
|||
<Tabs {selected}> |
|||
<Tab title="Edit"> |
|||
<div class="template-editor"> |
|||
<Editor |
|||
editorHeight={800} |
|||
bind:this={htmlEditor} |
|||
mode="handlebars" |
|||
on:change={e => { |
|||
selectedTemplate.contents = e.detail.value |
|||
}} |
|||
value={selectedTemplate.contents} |
|||
/> |
|||
<div class="bindings-editor"> |
|||
<Detail size="L">Bindings</Detail> |
|||
<Tabs selected={selectedBindingTab}> |
|||
<Tab title="Template"> |
|||
<TemplateBindings |
|||
title="Template Bindings" |
|||
bindings={templateBindings} |
|||
onBindingClick={setTemplateBinding} |
|||
/> |
|||
</Tab> |
|||
<Tab title="Common"> |
|||
<TemplateBindings |
|||
title="Common Bindings" |
|||
bindings={$email.definitions.bindings.common} |
|||
onBindingClick={setTemplateBinding} |
|||
/> |
|||
</Tab> |
|||
</Tabs> |
|||
</div> |
|||
</Tab> |
|||
<Tab title="Preview"> |
|||
<div class="preview" transition:fade> |
|||
{@html selectedTemplate.contents} |
|||
</div> |
|||
</Tab> |
|||
</Tabs> |
|||
</Page> |
|||
|
|||
<style> |
|||
.template-editor { |
|||
display: grid; |
|||
grid-template-columns: 1fr 20%; |
|||
grid-gap: var(--spacing-xl); |
|||
margin-top: var(--spacing-xl); |
|||
} |
|||
|
|||
header { |
|||
display: flex; |
|||
width: 100%; |
|||
justify-content: space-between; |
|||
} |
|||
|
|||
.preview { |
|||
background: white; |
|||
height: 800px; |
|||
padding: var(--spacing-xl); |
|||
} |
|||
|
|||
.backbutton { |
|||
display: flex; |
|||
gap: var(--spacing-m); |
|||
margin-bottom: var(--spacing-xl); |
|||
cursor: pointer; |
|||
} |
|||
|
|||
</style> |
|||
@ -0,0 +1,44 @@ |
|||
import { writable } from "svelte/store" |
|||
import api from "builderStore/api" |
|||
|
|||
export function createEmailStore() { |
|||
const store = writable([]) |
|||
|
|||
return { |
|||
subscribe: store.subscribe, |
|||
templates: { |
|||
fetch: async () => { |
|||
// fetch the email template definitions
|
|||
const response = await api.get(`/api/admin/template/definitions`) |
|||
const definitions = await response.json() |
|||
|
|||
// fetch the email templates themselves
|
|||
const templatesResponse = await api.get(`/api/admin/template/email`) |
|||
const templates = await templatesResponse.json() |
|||
|
|||
store.set({ |
|||
definitions, |
|||
templates, |
|||
}) |
|||
}, |
|||
save: async template => { |
|||
// Save your template config
|
|||
const response = await api.post(`/api/admin/template`, template) |
|||
const json = await response.json() |
|||
if (response.status !== 200) throw new Error(json.message) |
|||
template._rev = json._rev |
|||
template._id = json._id |
|||
|
|||
store.update(state => { |
|||
const currentIdx = state.templates.findIndex( |
|||
template => template.purpose === json.purpose |
|||
) |
|||
state.templates.splice(currentIdx, 1, template) |
|||
return state |
|||
}) |
|||
}, |
|||
}, |
|||
} |
|||
} |
|||
|
|||
export const email = createEmailStore() |
|||
@ -1,3 +1,4 @@ |
|||
export { organisation } from "./organisation" |
|||
export { admin } from "./admin" |
|||
export { apps } from "./apps" |
|||
export { email } from "./email" |
|||
|
|||
File diff suppressed because it is too large
@ -0,0 +1,93 @@ |
|||
const authPkg = require("@budibase/auth") |
|||
const { google } = require("@budibase/auth/src/middleware") |
|||
const { Configs } = require("../../constants") |
|||
const CouchDB = require("../../db") |
|||
const { clearCookie } = authPkg.utils |
|||
const { Cookies } = authPkg.constants |
|||
const { passport } = authPkg.auth |
|||
|
|||
const GLOBAL_DB = authPkg.StaticDatabases.GLOBAL.name |
|||
|
|||
exports.authenticate = async (ctx, next) => { |
|||
return passport.authenticate("local", async (err, user) => { |
|||
if (err) { |
|||
return ctx.throw(403, "Unauthorized") |
|||
} |
|||
|
|||
const expires = new Date() |
|||
expires.setDate(expires.getDate() + 1) |
|||
|
|||
if (!user) { |
|||
return ctx.throw(403, "Unauthorized") |
|||
} |
|||
|
|||
ctx.cookies.set(Cookies.Auth, user.token, { |
|||
expires, |
|||
path: "/", |
|||
httpOnly: false, |
|||
overwrite: true, |
|||
}) |
|||
|
|||
delete user.token |
|||
|
|||
ctx.body = { user } |
|||
})(ctx, next) |
|||
} |
|||
|
|||
exports.logout = async ctx => { |
|||
clearCookie(ctx, Cookies.Auth) |
|||
ctx.body = { message: "User logged out" } |
|||
} |
|||
|
|||
/** |
|||
* The initial call that google authentication makes to take you to the google login screen. |
|||
* On a successful login, you will be redirected to the googleAuth callback route. |
|||
*/ |
|||
exports.googlePreAuth = async (ctx, next) => { |
|||
const db = new CouchDB(GLOBAL_DB) |
|||
const config = await authPkg.db.getScopedFullConfig(db, { |
|||
type: Configs.GOOGLE, |
|||
group: ctx.query.group, |
|||
}) |
|||
const strategy = await google.strategyFactory(config) |
|||
|
|||
return passport.authenticate(strategy, { |
|||
scope: ["profile", "email"], |
|||
})(ctx, next) |
|||
} |
|||
|
|||
exports.googleAuth = async (ctx, next) => { |
|||
const db = new CouchDB(GLOBAL_DB) |
|||
|
|||
const config = await authPkg.db.getScopedFullConfig(db, { |
|||
type: Configs.GOOGLE, |
|||
group: ctx.query.group, |
|||
}) |
|||
const strategy = await google.strategyFactory(config) |
|||
|
|||
return passport.authenticate( |
|||
strategy, |
|||
{ successRedirect: "/", failureRedirect: "/error" }, |
|||
async (err, user) => { |
|||
if (err) { |
|||
return ctx.throw(403, "Unauthorized") |
|||
} |
|||
|
|||
const expires = new Date() |
|||
expires.setDate(expires.getDate() + 1) |
|||
|
|||
if (!user) { |
|||
return ctx.throw(403, "Unauthorized") |
|||
} |
|||
|
|||
ctx.cookies.set(Cookies.Auth, user.token, { |
|||
expires, |
|||
path: "/", |
|||
httpOnly: false, |
|||
overwrite: true, |
|||
}) |
|||
|
|||
ctx.redirect("/") |
|||
} |
|||
)(ctx, next) |
|||
} |
|||
Loading…
Reference in new issue