Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
311 changes: 232 additions & 79 deletions docs/.well-known/llms-full.txt

Large diffs are not rendered by default.

136 changes: 101 additions & 35 deletions docs/API/core/app-actions-v3.md

Large diffs are not rendered by default.

23 changes: 19 additions & 4 deletions docs/API/fliplet-session.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,31 @@ Read, write, and clear the current user session — including cached offline ses
// Get the session using the locally cached offline data.
// This is fast and work across all network conditions.
Fliplet.User.getCachedSession().then(function onCachedSessionRetrieved(session) {
console.log(session);
// Use the session for UI state; do not log authentication fields.
});

// This only works if the app is online as it makes an API request
// to Fliplet servers to return the live data.
// Request the current session. If the request fails, get() can return
// a stored session; use the fresh-read method below for access checks.
Fliplet.Session.get().then(function onSessionRetrieved(session) {
console.log(session);
// Use the session for UI state; do not log authentication fields.
});
```

### Read the session without a stored-session fallback

Use `Fliplet.Session.run({ url: 'v1/session', cache: false })` when a failed session read must stop an operation. Include `fliplet-session` in an app action's dependencies. The promise resolves to a response object containing `session`; it rejects when the request fails. Treat a missing or malformed `response.session` as a failed access check.

```js
const response = await Fliplet.Session.run({ url: 'v1/session', cache: false });
const session = response && response.session;
const login = session && session.entries && session.entries.dataSource;
// For Data Source login, login.dataSourceId identifies the login Data Source
// and login.id identifies its authenticated entry. Check the intended source
// and operation's permissions before granting access.
```

`Fliplet.Session.get()` also requests the session, but can fall back to a stored session if the request fails. `Fliplet.User.getCachedSession()` is appropriate for cached UI decisions; neither a cached result nor the existence of a session establishes permission for a private operation. In a server action, use the fresh read above and follow [caller authorization](core/app-actions-v3#authorize-callers-before-accessing-private-data). Do not log the session response or its authentication fields.

### Get a key from the current session

```js
Expand Down
4 changes: 2 additions & 2 deletions docs/API/v3/app-bootstrap.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,14 @@ Each constraint maps to a concrete platform guarantee:

1. Dependencies resolve through the Fliplet asset pipeline so versioning, caching, and per-environment injection work.
2. Source files are fetched with signed media requests so private apps don't leak their screens.
3. The runtime registers its globals (`Fliplet.ENV`, `Fliplet.Router`, `Fliplet.Session`, etc.) before your framework mounts.
3. `Fliplet().then(...)` waits for runtime initialization before your framework mounts. Optional packages such as `Fliplet.Session` must also be loaded before use.
4. The viewport is locked so native apps don't pinch-zoom or auto-zoom when users focus an input field.

<p class="warning">Skip any of these constraints and the app fails at boot, works in dev but breaks in production, or is rejected by the boot-HTML lint.</p>

## 1. Fetch dependencies with `Fliplet.require.lazy`

Any registered Fliplet dependency (Vue, React, Vue Router, etc.) **must** be loaded with `Fliplet.require.lazy(name)`. Never use `<script>` tags, `import`, or raw CDN URLs. These bypass the Fliplet asset pipeline and break versioning, caching, and dependency resolution.
Use `Fliplet.require.lazy(name)` for a registered single dependency, such as Vue. For a registered package with transitive dependencies, use `Fliplet.require.lazy.chain(name)`, for example `Fliplet.require.lazy.chain('fliplet-session')`, to load its prerequisites in order. Plain `lazy(name)` rejects chained packages. Never use `<script>` tags, `import`, or raw CDN URLs. These bypass the Fliplet asset pipeline and break versioning, caching, and dependency resolution.

```js
Fliplet.require.lazy('vue').then(function() {
Expand Down
2 changes: 1 addition & 1 deletion docs/API/v3/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,7 @@ async function isLoggedIn() {
}
```

`Fliplet.User.getCachedSession()` is fast and works offline because it reads from local storage. Use it for UI decisions (show/hide content). For server-verified session checks, use `Fliplet.Session.get()` instead (requires network).
`Fliplet.User.getCachedSession()` is fast and works offline because it reads from local storage. Use it for UI decisions (show/hide content). `Fliplet.Session.get()` requests the session but can fall back to a stored session on failure. When an access decision requires a fresh read that fails on request errors, use the [session read without stored-session fallback](../fliplet-session#read-the-session-without-a-stored-session-fallback).

### Get the Logged-In User's Data

Expand Down
144 changes: 108 additions & 36 deletions docs/API/v3/routing.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ Four requirements:
- It returns `{ allowed: false, redirectTo, reason }` on denial, where `reason` is `'no-session'` or `'media-denied'`.
- It already fetches the screen source via `Fliplet.Media.getContents(fileId)` internally. Don't call it yourself.
- It rejects on transient or infra errors (5xx, network) so you can show an error UI instead of redirecting silently.
4. **Guard against stale navigations.** If the user has navigated away before an access check resolves, don't redirect. Compare the resolved route against the current route before calling `push`.
4. **Guard against stale navigations.** Capture the full target path and a request identifier before loading. Ignore results after a newer load, navigation or unmount, including results from asynchronous screen parsing. Do not compare a pending navigation only with the previously committed route.

<p class="warning"><strong>Server media ACL is the final gate.</strong> The manifest's <code>public: true</code> flag is advisory. It just lets the client skip a known-401 round trip. Flipping <code>public: true</code> in the manifest without updating the media file's access rule grants no access.</p>

Expand Down Expand Up @@ -89,6 +89,8 @@ Every example assumes this manifest shape (see [App settings](app-settings) for

### Vue Router 4 (Vue 3)

Use a stable wrapper component for each manifest route. Vue Router caches resolved async route components, so an async component factory must not return a temporary access-denied or loading component. The wrapper below checks access on creation and on full-path changes, clears the previous screen, and ignores stale access or parsing results. `parseScreen(content)` is your framework-specific screen parser and may return a promise.

```js
Fliplet.require.lazy('vue-router').then(function() {
var manifest = Fliplet.Router.getRouteManifest();
Expand All @@ -98,25 +100,60 @@ Fliplet.require.lazy('vue-router').then(function() {
? VueRouter.createWebHashHistory() // native — hash only
: VueRouter.createWebHistory(Fliplet.Router.getBasePath()); // web — path + basename

var routes = manifest.routes.map(function(r) {
function routeScreen(path) {
return {
path: r.path,
name: r.name,
component: function() {
return Fliplet.Router.resolveRoute(r.path).then(function(result) {
if (!result.allowed) {
// Race guard: only redirect if the user is still on this route.
if (router.currentRoute.value.path === r.path) {
router.push(result.redirectTo);
data: function() {
return { loading: true, error: '', screen: null, requestId: 0 };
},
created: function() { this.load(); },
watch: {
'$route.fullPath': function() { this.load(); }
},
beforeUnmount: function() { this.requestId++; },
methods: {
load: async function() {
var id = ++this.requestId;
var fullPath = this.$route.fullPath;
var current = () => id === this.requestId && this.$route.fullPath === fullPath;
this.screen = null;
this.error = '';
this.loading = true;

try {
// Resolve the manifest path; keep query/hash in the navigation identity.
var result = await Fliplet.Router.resolveRoute(path);
if (!current()) return;

if (!result.allowed) {
if (!result.redirectTo || result.redirectTo === this.$route.path) {
this.error = 'Access denied.';
return;
}

sessionStorage.setItem('fl:postLoginPath', fullPath);
await this.$router.replace(result.redirectTo);
return;
}

return { template: '<div></div>' };
var screen = await parseScreen(result.content);
if (!current()) return;
this.screen = Vue.markRaw(screen);
} catch (error) {
if (current()) this.error = 'Unable to load this screen. Please retry.';
} finally {
if (current()) this.loading = false;
}

return parseScreen(result.content); // framework-specific
});
}
}
},
template: '<div><p v-if="loading" role="status">Loading…</p>' +
'<div v-else-if="error" role="alert"><p v-text="error"></p>' +
'<button type="button" @click="load">Retry</button></div>' +
'<component v-else-if="screen" :is="screen" /></div>'
};
}

var routes = manifest.routes.map(function(r) {
return { path: r.path, name: r.name, component: routeScreen(r.path) };
});

routes.unshift({ path: '/', redirect: manifest.defaultRoute });
Expand All @@ -128,30 +165,66 @@ Fliplet.require.lazy('vue-router').then(function() {

### Vue Router 3 (Vue 2)

Use the same wrapper lifecycle with Vue 2’s `beforeDestroy` hook. Vue 2 does not need `Vue.markRaw`.

```js
Fliplet.require.lazy('vue-router').then(function() {
var manifest = Fliplet.Router.getRouteManifest();

var routes = manifest.routes.map(function(r) {
function routeScreen(path) {
return {
path: r.path,
name: r.name,
component: function(resolve) {
Fliplet.Router.resolveRoute(r.path).then(function(result) {
if (!result.allowed) {
if (router.currentRoute.path === r.path) {
router.push(result.redirectTo);
data: function() {
return { loading: true, error: '', screen: null, requestId: 0 };
},
created: function() { this.load(); },
watch: {
'$route.fullPath': function() { this.load(); }
},
beforeDestroy: function() { this.requestId++; },
methods: {
load: async function() {
var id = ++this.requestId;
var fullPath = this.$route.fullPath;
var current = () => id === this.requestId && this.$route.fullPath === fullPath;
this.screen = null;
this.error = '';
this.loading = true;

try {
// Resolve the manifest path; keep query/hash in the navigation identity.
var result = await Fliplet.Router.resolveRoute(path);
if (!current()) return;

if (!result.allowed) {
if (!result.redirectTo || result.redirectTo === this.$route.path) {
this.error = 'Access denied.';
return;
}

sessionStorage.setItem('fl:postLoginPath', fullPath);
await this.$router.replace(result.redirectTo);
return;
}

resolve({ template: '<div></div>' });

return;
var screen = await parseScreen(result.content);
if (!current()) return;
this.screen = screen;
} catch (error) {
if (current()) this.error = 'Unable to load this screen. Please retry.';
} finally {
if (current()) this.loading = false;
}

resolve(parseScreen(result.content));
});
}
}
},
template: '<div><p v-if="loading" role="status">Loading…</p>' +
'<div v-else-if="error" role="alert"><p v-text="error"></p>' +
'<button type="button" @click="load">Retry</button></div>' +
'<component v-else-if="screen" :is="screen" /></div>'
};
}

var routes = manifest.routes.map(function(r) {
return { path: r.path, name: r.name, component: routeScreen(r.path) };
});

routes.unshift({ path: '/', redirect: manifest.defaultRoute });
Expand Down Expand Up @@ -315,15 +388,12 @@ render(currentPath());

## Post-login redirect

When `resolveRoute` returns `reason: 'no-session'` or `reason: 'media-denied'`, stash the intended path before navigating to `authRedirect`. After a successful login, consume it.
When `resolveRoute` denies access, stash the intended app-relative full path before redirecting, as the Vue wrappers above do. After a successful login, consume it. Do not redirect a denied login route to itself; show an access error. After logout succeeds, navigate to the login route so the protected wrapper is destroyed; a session change alone does not trigger navigation or recheck an already mounted screen.

### Vue Router 4 example

```js
// Before redirecting to the auth screen:
sessionStorage.setItem('fl:postLoginPath', router.currentRoute.value.fullPath);
router.push(result.redirectTo);

// The wrapper has already saved the intended full path.
// After a successful login:
var target = sessionStorage.getItem('fl:postLoginPath');
sessionStorage.removeItem('fl:postLoginPath');
Expand All @@ -334,7 +404,9 @@ router.push(target || manifest.defaultRoute);

```js
// Before redirecting to the auth screen:
sessionStorage.setItem('fl:postLoginPath', location.pathname + location.search);
// Capture this from your router: app-relative path, query and fragment.
// Do not store the hosting base path or native file URL.
sessionStorage.setItem('fl:postLoginPath', intendedFullPath);
navigate(result.redirectTo);

// After a successful login:
Expand Down
Loading