Commit 0394b4bd by Jonathan Thomas

Add isolated demo access to Simple Editor

parent 8d8b69b7
VUE_APP_OPENSHOT_API_URL=https://cloud.openshot.org
VUE_APP_OPENSHOT_API_URL=http://127.0.0.1:8090
VUE_APP_GA_ID=UA-4381101-7
......@@ -24,6 +24,7 @@
<router-link to="/logout" class="nav-link" aria-current="page">Logout</router-link>
</li>
</ul>
<DemoExpiryNotice v-if="isAuthenticated" @expired="demoExpired" />
</div>
</div>
</nav>
......@@ -50,12 +51,19 @@
<script>
import { mapGetters, mapActions, mapMutations, mapState } from 'vuex'
import DemoExpiryNotice from './components/DemoExpiryNotice.vue'
export default {
components: { DemoExpiryNotice },
methods: {
removeError(error) {
this.removeError(error)
},
async demoExpired() {
localStorage.removeItem('auth')
this.setUser(null)
await this.$router.push('/login')
},
...mapActions(['login']),
...mapMutations(['setUser','removeError'])
},
......@@ -72,6 +80,7 @@ export default {
// auto-login user (to populate vuex user object)
await this.login(auth)
if (!this.isAuthenticated) {
localStorage.removeItem('auth')
// login failed, show login page
await this.$router.push('/login')
}
......
......@@ -94,6 +94,10 @@
</div>
<div class="modal-body">
<div v-if="exportError" class="alert alert-danger" role="alert">
<strong>Export could not start.</strong> {{ exportError }}
</div>
<!-- Progress Bar for Export -->
<div v-show="isExportInProgress" class="col-sm-12 mb-2">
<p class="text-muted small mb-1">Please wait while your video is being exported.</p>
......@@ -202,6 +206,7 @@ export default {
return {
show_spinner: false,
exporting: false,
exportError: null,
draggingClip: null,
draggingIndex: null,
dragOverIndex: null,
......@@ -487,6 +492,7 @@ export default {
},
async startExport() {
this.exporting = true
this.exportError = null
if (this.$store && this.$store.commit) {
this.$store.commit('setExport', { progress: 0.0 })
}
......@@ -501,7 +507,13 @@ export default {
"project": this.project.url,
"json": {}
}
try {
await this.createExport(payload)
} catch (error) {
const detail = error.response?.data?.project || error.response?.data?.detail || error.message
this.exportError = Array.isArray(detail) ? detail.join(' ') : String(detail)
this.exporting = false
}
},
deleteClipBtn(clip_id) {
this.deleteClip(clip_id)
......
<template>
<div v-if="show" class="demo-backdrop" @click.self="close">
<section class="demo-modal" role="dialog" aria-modal="true" aria-labelledby="demo-access-title">
<header>
<button class="close-button" type="button" aria-label="Close" @click="close">&times;</button>
<div class="title-row"><div class="demo-icon">24h</div><div><h2 id="demo-access-title">Temporary Demo Access</h2><p>Private credentials for Simple Editor or HTTP Basic Auth.</p></div></div>
</header>
<div v-if="!access" class="demo-body">
<ul>
<li>Nothing is shared with other demo users</li>
<li>No name or email address required</li>
<li>Account and files expire 24 hours after creation</li>
</ul>
<div class="demo-warning">Evaluation only. Do not upload illegal, explicit, confidential, regulated, or sensitive personal content.</div>
<div v-if="error" class="alert alert-danger mt-3 mb-0">{{ error }}</div>
</div>
<div v-else class="demo-body credentials-body">
<div class="ready">Your account is ready. Copy these credentials now—they cannot be shown again.</div>
<label>Username</label>
<div class="credential"><code>{{ access.username }}</code><button type="button" @click="copy(access.username, 'username')">{{ copied === 'username' ? 'Copied' : 'Copy' }}</button></div>
<label>Password</label>
<div class="credential"><code>{{ access.password }}</code><button type="button" @click="copy(access.password, 'password')">{{ copied === 'password' ? 'Copied' : 'Copy' }}</button></div>
<button type="button" class="copy-both" @click="copyBoth">{{ copied === 'both' ? 'Credentials copied' : 'Copy username & password' }}</button>
</div>
<footer v-if="!access">
<button type="button" class="btn btn-light" @click="close">Cancel</button>
<button type="button" class="btn btn-primary" :disabled="creating" @click="generate">
{{ creating ? 'Creating…' : 'Create Demo Account' }}
</button>
</footer>
<footer v-else>
<button type="button" class="btn btn-primary" @click="continueToEditor">Continue to Simple Editor</button>
</footer>
</section>
</div>
</template>
<script>
import { instance } from '../store/axios'
export default {
name: 'DemoAccessModal',
props: { show: { type: Boolean, default: false } },
emits: ['close', 'created'],
data() { return { creating: false, error: null, access: null, auth: null, copied: null } },
methods: {
close() { if (!this.creating) this.$emit('close') },
async generate() {
this.creating = true
this.error = null
try {
const response = await instance.post('/demo-access/')
const auth = `Basic ${btoa(`${response.data.username}:${response.data.password}`)}`
localStorage.auth = auth
this.auth = auth
this.access = response.data
} catch (error) {
this.error = error.response?.data?.detail || 'Unable to create temporary demo access.'
} finally {
this.creating = false
}
},
async copy(value, field) {
await navigator.clipboard.writeText(value)
this.copied = field
},
async copyBoth() {
await navigator.clipboard.writeText(`Username: ${this.access.username}\nPassword: ${this.access.password}`)
this.copied = 'both'
},
continueToEditor() {
this.$emit('created', { auth: this.auth, access: this.access })
}
}
}
</script>
<style scoped>
.demo-backdrop { position: fixed; inset: 0; z-index: 1055; display: grid; place-items: center; padding: 20px; background: rgba(15, 23, 42, .58); backdrop-filter: blur(3px); }
.demo-modal { width: min(460px, 100%); overflow: hidden; border-radius: 14px; background: white; box-shadow: 0 20px 60px rgba(15, 23, 42, .25); }
header { position: relative; padding: 22px 24px 18px; background: linear-gradient(135deg, #eff6ff, #eef2ff); }
.title-row { display: flex; align-items: center; gap: 14px; }
header h2 { margin: 0 0 3px; color: #111827; font-size: 21px; }
header p { margin: 0; color: #4b5563; }
.close-button { position: absolute; top: 14px; right: 18px; border: 0; background: transparent; color: #64748b; font-size: 30px; line-height: 1; }
.demo-icon { width: 42px; height: 42px; flex: 0 0 42px; display: grid; place-items: center; border-radius: 11px; background: #2563eb; color: white; font-weight: 700; box-shadow: 0 6px 16px rgba(37, 99, 235, .22); }
.demo-body { padding: 20px 24px; }
ul { margin: 0 0 18px; padding-left: 22px; color: #374151; }
li { margin: 8px 0; }
.demo-warning { padding: 12px 14px; border: 1px solid #fde68a; border-radius: 10px; background: #fffbeb; color: #78350f; font-size: 13px; line-height: 1.5; }
footer { display: flex; justify-content: flex-end; gap: 10px; padding: 0 24px 20px; }
.ready { margin-bottom: 16px; color: #065f46; font-size: 13px; line-height: 1.45; }
label { display: block; margin-bottom: 4px; color: #64748b; font-size: 11px; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; }
.credential { margin-bottom: 12px; padding: 9px 11px; display: flex; align-items: center; justify-content: space-between; gap: 10px; border: 1px solid #dbe3ef; border-radius: 8px; background: #f8fafc; }
.credential code { color: #111827; word-break: break-all; }
.credential button, .copy-both { border: 0; background: transparent; color: #2563eb; font-weight: 600; white-space: nowrap; }
.copy-both { padding: 0; font-size: 13px; }
</style>
<template>
<div v-if="isDemo" class="expiry-notice">
<span class="badge">Temporary Demo</span>
<span class="time"><strong>{{ remaining }}</strong> left</span>
</div>
</template>
<script>
import { instance } from '../store/axios'
export default {
name: 'DemoExpiryNotice',
emits: ['expired'],
data() { return { isDemo: false, expiresAt: null, remaining: '--:--:--', timer: null } },
async mounted() {
try {
const response = await instance.get('/demo-access/status/')
if (response.data.is_demo) {
this.isDemo = true
const secondsRemaining = Math.max(1, Number(response.data.seconds_remaining) || 0)
this.expiresAt = new Date(Date.now() + secondsRemaining * 1000)
this.tick()
this.timer = window.setInterval(this.tick, 1000)
}
} catch (_) {
this.isDemo = false
}
},
beforeUnmount() { if (this.timer) window.clearInterval(this.timer) },
methods: {
tick() {
const seconds = Math.max(0, Math.floor((this.expiresAt.getTime() - Date.now()) / 1000))
const hours = String(Math.floor(seconds / 3600)).padStart(2, '0')
const minutes = String(Math.floor((seconds % 3600) / 60)).padStart(2, '0')
const remainder = String(seconds % 60).padStart(2, '0')
this.remaining = `${hours}:${minutes}:${remainder}`
if (seconds === 0) {
window.clearInterval(this.timer)
this.$emit('expired')
}
}
}
}
</script>
<style scoped>
.expiry-notice { margin-left: auto; padding: 6px 10px; display: inline-flex; align-items: center; gap: 8px; border: 1px solid #fde68a; border-radius: 999px; background: linear-gradient(135deg, #fffbeb, #fefce8); color: #78350f; font-size: 12px; white-space: nowrap; }
.badge { padding: 3px 6px; border-radius: 999px; background: rgba(234, 179, 8, .14); color: #92400e; font-size: 10px; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; }
.time { display: inline-flex; gap: 4px; align-items: baseline; }
strong { font-variant-numeric: tabular-nums; }
@media (max-width: 991.98px) { .expiry-notice { margin: 8px 0 4px; } }
</style>
......@@ -543,8 +543,10 @@ export default createStore({
try {
const response = await instance.post('/exports/', payload)
commit('setExport', response.data)
return response.data
} catch(err) {
commit('addError', err.response?.data || err.message)
throw err
}
},
async loadExports({commit}, project_id) {
......
<template>
<!-- Login container -->
<div v-if="isDemoEnvironment" class="row ps-5 pe-5 justify-content-center">
<div class="col-xs-12 col-md-8 col-lg-6">
<div class="alert alert-primary" role="alert">
<h4 class="mb-2">Demo Credentials</h4>
<p class="mb-2">
<strong>User:</strong> demo-cloud,
<strong>Password:</strong> demo-password
</p>
<CloudInstancePromo
label="Unlock the full experience"
title="Launch your own instance"
:subtitle-html="launchSubtitleHtml"
/>
</div>
</div>
<DemoAccessModal :show="showDemoAccess" @close="showDemoAccess = false" @created="demoCreated" />
<main class="login-shell">
<section v-if="isDemoEnvironment" class="demo-entry">
<div class="demo-copy">
<span class="eyebrow">24-hour private sandbox</span>
<h1>Try Simple Editor</h1>
<p>Generate isolated credentials for the editor or your own API requests. No signup, name, or email required.</p>
<div class="demo-details"><span>Private workspace</span><span>Automatic cleanup</span></div>
</div>
<button type="button" class="btn btn-primary demo-button" @click="showDemoAccess = true">Create demo account</button>
</section>
<div class="row p-5 justify-content-center">
<div class="col-xs-12 col-md-6 col-lg-4">
<div class="mb-3">
<section class="existing-login" :class="{ 'standalone-login': !isDemoEnvironment }">
<h2>{{ isDemoEnvironment ? 'Use existing credentials' : 'Sign in' }}</h2>
<p v-if="isDemoEnvironment">Already generated an account, or connecting to your own server?</p>
<p v-else>Enter the credentials for your OpenShot Cloud API server.</p>
<div class="login-fields">
<div>
<label class="form-label">Username</label>
<input type="text" class="form-control" v-model="username">
<input type="text" class="form-control" autocomplete="username" v-model="username">
</div>
<div class="mb-3">
<div>
<label class="form-label">Password</label>
<input type="password" class="form-control" v-model="password" @keyup.enter="loginClick">
<input type="password" class="form-control" autocomplete="current-password" v-model="password" @keyup.enter="loginClick">
</div>
<!-- Login button -->
<button type="submit" class="btn btn-primary" @click="loginClick">Login</button>
</div>
<button type="submit" class="btn login-button" :class="isDemoEnvironment ? 'btn-outline-primary' : 'btn-primary'" @click="loginClick">Sign in</button>
</section>
<section v-if="isDemoEnvironment" class="persistent-promo">
<div><strong>Need persistent projects?</strong><span>Launch your own OpenShot Cloud API instance.</span></div>
<div class="provider-links">
<a href="https://aws.amazon.com/marketplace/pp/B074H87FSJ/" target="_blank" rel="noopener" title="Launch on AWS"><img src="//cdn.openshot.org/static/img/icons/aws.svg" alt="AWS"></a>
<a href="https://azuremarketplace.microsoft.com/en-us/marketplace/apps/openshotstudiosllc.openshot-cloud-api" target="_blank" rel="noopener" title="Launch on Azure"><img src="//cdn.openshot.org/static/img/icons/azure.svg" alt="Azure"></a>
<a href="https://console.cloud.google.com/marketplace/product/openshotstudios-public/openshot-video-editing-cloud-api" target="_blank" rel="noopener" title="Launch on Google Cloud"><img src="//cdn.openshot.org/static/img/icons/google.png" alt="Google Cloud"></a>
</div>
</section>
</main>
</template>
<script>
import { mapActions, mapGetters, mapMutations } from 'vuex'
import CloudInstancePromo from '../components/CloudInstancePromo.vue'
import DemoAccessModal from '../components/DemoAccessModal.vue'
import { instance } from '../store/axios'
export default {
name: "Login.vue",
components: {
CloudInstancePromo
},
components: { DemoAccessModal },
data() {
return {
username: null,
password: null
password: null,
showDemoAccess: false,
demoAccessEnabled: false
}
},
methods: {
......@@ -64,30 +72,61 @@ export default {
localStorage.removeItem('auth')
}
},
async demoCreated({auth}) {
this.showDemoAccess = false
await this.login(auth)
if (this.isAuthenticated) await this.$router.push('/')
},
...mapActions(['login']),
...mapMutations(['clearErrors'])
},
computed: {
getCloudApiUrl() {
return process.env.VUE_APP_OPENSHOT_API_URL
},
isDemoEnvironment() {
const apiUrl = (this.getCloudApiUrl || '').trim()
if (!apiUrl) {
return false
}
const normalized = apiUrl.replace(/\/+$/, '').toLowerCase()
return normalized === 'https://cloud.openshot.org'
},
launchSubtitleHtml() {
const link = '<a href="https://www.openshot.org/cloud-api/" target="_blank" rel="noopener">OpenShot Cloud API</a>'
return `Deploy ${link} on AWS, Azure, or Google Cloud for full-length exports and persistent projects.`
return this.demoAccessEnabled
},
...mapGetters(['isAuthenticated'])
},
async created() {
try {
const response = await instance.get('/demo-access/status/')
this.demoAccessEnabled = response.data.demo_access_enabled === true
} catch (error) {
this.demoAccessEnabled = false
}
}
}
</script>
<style scoped>
.login-shell { width: min(680px, calc(100% - 24px)); margin: 30px auto 70px; color: #172033; }
.demo-entry { padding: 30px; display: flex; align-items: center; justify-content: space-between; gap: 28px; border: 1px solid #cdddfc; border-radius: 16px; background: linear-gradient(135deg, #f7faff, #edf3ff); box-shadow: 0 12px 30px rgba(37, 99, 235, .08); }
.demo-copy { max-width: 440px; }
.eyebrow { color: #2563eb; font-size: 12px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; }
h1 { margin: 7px 0 8px; font-size: 30px; font-weight: 650; }
.demo-copy p { margin: 0 0 14px; color: #526077; line-height: 1.55; }
.demo-details { display: flex; flex-wrap: wrap; gap: 8px 18px; color: #64748b; font-size: 12px; }
.demo-details span::before { content: '✓'; margin-right: 6px; color: #16a34a; font-weight: 700; }
.demo-button { flex: 0 0 auto; padding: 11px 17px; border-radius: 9px; font-weight: 600; }
.existing-login { padding: 25px 30px 22px; }
.standalone-login { margin: 54px auto 0; padding: 28px 30px 30px; border: 1px solid #dee2e6; border-radius: 12px; background: #fff; box-shadow: 0 12px 32px rgba(15, 23, 42, .07); }
.standalone-login h2 { font-size: 22px; }
.standalone-login .login-button { min-width: 96px; }
.existing-login h2 { margin: 0 0 3px; font-size: 18px; font-weight: 650; }
.existing-login > p { margin: 0 0 17px; color: #718096; font-size: 13px; }
.login-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.login-fields label { color: #526077; font-size: 13px; }
.login-button { margin-top: 14px; }
.persistent-promo { padding: 16px 20px; display: flex; align-items: center; justify-content: space-between; gap: 20px; border-top: 1px solid #e5eaf1; color: #526077; font-size: 13px; }
.persistent-promo strong, .persistent-promo span { display: block; }
.persistent-promo strong { color: #273449; margin-bottom: 2px; }
.provider-links { display: flex; align-items: center; gap: 8px; }
.provider-links a { width: 66px; height: 34px; display: grid; place-items: center; border: 1px solid #dce3ec; border-radius: 999px; background: white; transition: transform .15s ease, box-shadow .15s ease; }
.provider-links a:hover { transform: translateY(-1px); box-shadow: 0 5px 12px rgba(15, 23, 42, .09); }
.provider-links img { max-width: 48px; max-height: 20px; }
@media (max-width: 650px) {
.demo-entry { padding: 24px; align-items: flex-start; flex-direction: column; }
.demo-button { width: 100%; }
.login-fields { grid-template-columns: 1fr; }
.persistent-promo { align-items: flex-start; flex-direction: column; }
}
</style>
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment