mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-04-07 01:15:44 +02:00
Auth/OpenID: Implement Permissions via OpenID
* Ability to set group * Ability to set more advanced permissions * Modified TextInputWithLabel to provide an ability to specify a different placeholder then the name
This commit is contained in:
parent
8e5b7504ae
commit
56f1bfef50
@ -5,7 +5,7 @@
|
|||||||
>{{ label }}<em v-if="note" class="font-normal text-xs pl-2">{{ note }}</em></label
|
>{{ label }}<em v-if="note" class="font-normal text-xs pl-2">{{ note }}</em></label
|
||||||
>
|
>
|
||||||
</slot>
|
</slot>
|
||||||
<ui-text-input :placeholder="label" :inputId="identifier" ref="input" v-model="inputValue" :disabled="disabled" :readonly="readonly" :type="type" class="w-full" :class="inputClass" @blur="inputBlurred" />
|
<ui-text-input :placeholder="placeholder || label" :inputId="identifier" ref="input" v-model="inputValue" :disabled="disabled" :readonly="readonly" :type="type" class="w-full" :class="inputClass" @blur="inputBlurred" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -14,6 +14,7 @@ export default {
|
|||||||
props: {
|
props: {
|
||||||
value: [String, Number],
|
value: [String, Number],
|
||||||
label: String,
|
label: String,
|
||||||
|
placeholder: String,
|
||||||
note: String,
|
note: String,
|
||||||
type: {
|
type: {
|
||||||
type: String,
|
type: String,
|
||||||
|
@ -70,17 +70,42 @@
|
|||||||
<p class="pl-4 text-sm text-gray-300 mt-5">{{ $strings.LabelMatchExistingUsersByDescription }}</p>
|
<p class="pl-4 text-sm text-gray-300 mt-5">{{ $strings.LabelMatchExistingUsersByDescription }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center py-4 px-1">
|
<div class="flex items-center py-4 px-1 w-full">
|
||||||
<ui-toggle-switch labeledBy="auto-redirect-toggle" v-model="newAuthSettings.authOpenIDAutoLaunch" :disabled="savingSettings" />
|
<ui-toggle-switch labeledBy="auto-redirect-toggle" v-model="newAuthSettings.authOpenIDAutoLaunch" :disabled="savingSettings" />
|
||||||
<p id="auto-redirect-toggle" class="pl-4 whitespace-nowrap">{{ $strings.LabelAutoLaunch }}</p>
|
<p id="auto-redirect-toggle" class="pl-4 whitespace-nowrap">{{ $strings.LabelAutoLaunch }}</p>
|
||||||
<p class="pl-4 text-sm text-gray-300" v-html="$strings.LabelAutoLaunchDescription" />
|
<p class="pl-4 text-sm text-gray-300" v-html="$strings.LabelAutoLaunchDescription" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center py-4 px-1">
|
<div class="flex items-center py-4 px-1 w-full">
|
||||||
<ui-toggle-switch labeledBy="auto-register-toggle" v-model="newAuthSettings.authOpenIDAutoRegister" :disabled="savingSettings" />
|
<ui-toggle-switch labeledBy="auto-register-toggle" v-model="newAuthSettings.authOpenIDAutoRegister" :disabled="savingSettings" />
|
||||||
<p id="auto-register-toggle" class="pl-4 whitespace-nowrap">{{ $strings.LabelAutoRegister }}</p>
|
<p id="auto-register-toggle" class="pl-4 whitespace-nowrap">{{ $strings.LabelAutoRegister }}</p>
|
||||||
<p class="pl-4 text-sm text-gray-300">{{ $strings.LabelAutoRegisterDescription }}</p>
|
<p class="pl-4 text-sm text-gray-300">{{ $strings.LabelAutoRegisterDescription }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center pt-6 pb-1 px-1 w-full">Leave the following options empty to disable advanced group and permissions assignment, automatically assigning 'User' group then.</div>
|
||||||
|
<div class="flex items-center mb-2">
|
||||||
|
<div class="w-96">
|
||||||
|
<ui-text-input-with-label ref="openidGroupClaim" v-model="newAuthSettings.authOpenIDGroupClaim" :disabled="savingSettings" :placeholder="'groups'" :label="'Group Claim'" />
|
||||||
|
</div>
|
||||||
|
<p class="pl-4 text-sm text-gray-300 mt-5">
|
||||||
|
Name of the OpenID claim that contains a list of the user's groups. Commonly referred to as <code>groups</code>. <b>If configured</b>, the application will automatically assign roles based on the user's group memberships, provided that these groups are named case-insensitively 'admin', 'user', or 'guest' in the claim. The claim should contain a list, and if a user belongs to
|
||||||
|
multiple groups, the application will assign the role corresponding to the highest level of access. If no group matches, access will be denied.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex mb-2">
|
||||||
|
<div class="w-96 pt-6">
|
||||||
|
<ui-text-input-with-label ref="openidAdvancedPermsClaim" v-model="newAuthSettings.authOpenIDAdvancedPermsClaim" :disabled="savingSettings" :placeholder="'abspermissions'" :label="'Advanced Permission Claim'" />
|
||||||
|
</div>
|
||||||
|
<div class="pl-4 text-sm text-gray-300 mt-5 flex-column">
|
||||||
|
<p class="">
|
||||||
|
Name of the OpenID claim that contains advanced permissions for user actions within the application which will apply to non-admin roles (<b>if configured</b>). If the claim is missing from the response, access to ABS will be denied. If a single option is missing, it will be treated as <code>false</code>. Ensure the identity provider's claim matches the expected structure:
|
||||||
|
</p>
|
||||||
|
<pre class="text-pre-wrap mt-2"
|
||||||
|
>{{ newAuthSettings.authOpenIDSamplePermissions }}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</transition>
|
</transition>
|
||||||
</div>
|
</div>
|
||||||
@ -222,6 +247,22 @@ export default {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isValidClaim(claim) {
|
||||||
|
if (claim === '') return true
|
||||||
|
|
||||||
|
const pattern = new RegExp('^[a-zA-Z][a-zA-Z0-9_-]*$', 'i')
|
||||||
|
return pattern.test(claim)
|
||||||
|
}
|
||||||
|
if (!isValidClaim(this.newAuthSettings.authOpenIDGroupClaim)) {
|
||||||
|
this.$toast.error('Group Claim: Invalid claim name')
|
||||||
|
isValid = false
|
||||||
|
}
|
||||||
|
if (!isValidClaim(this.newAuthSettings.authOpenIDAdvancedPermsClaim)) {
|
||||||
|
this.$toast.error('Advanced Permission Claim: Invalid claim name')
|
||||||
|
isValid = false
|
||||||
|
}
|
||||||
|
|
||||||
return isValid
|
return isValid
|
||||||
},
|
},
|
||||||
async saveSettings() {
|
async saveSettings() {
|
||||||
|
@ -98,7 +98,7 @@ class Auth {
|
|||||||
scope: 'openid profile email'
|
scope: 'openid profile email'
|
||||||
}
|
}
|
||||||
}, async (tokenset, userinfo, done) => {
|
}, async (tokenset, userinfo, done) => {
|
||||||
Logger.debug(`[Auth] openid callback userinfo=`, userinfo)
|
Logger.debug(`[Auth] openid callback userinfo=`, JSON.stringify(userinfo, null, 2))
|
||||||
|
|
||||||
let failureMessage = 'Unauthorized'
|
let failureMessage = 'Unauthorized'
|
||||||
if (!userinfo.sub) {
|
if (!userinfo.sub) {
|
||||||
@ -106,6 +106,35 @@ class Auth {
|
|||||||
return done(null, null, failureMessage)
|
return done(null, null, failureMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if the claims itself are returned correctly
|
||||||
|
const groupClaimName = Database.serverSettings.authOpenIDGroupClaim;
|
||||||
|
if (groupClaimName) {
|
||||||
|
if (!userinfo[groupClaimName]) {
|
||||||
|
Logger.error(`[Auth] openid callback invalid: Group claim ${groupClaimName} configured, but not found or empty in userinfo`)
|
||||||
|
return done(null, null, failureMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupsList = userinfo[groupClaimName]
|
||||||
|
const targetRoles = ['admin', 'user', 'guest']
|
||||||
|
|
||||||
|
// Convert the list to lowercase for case-insensitive comparison
|
||||||
|
const groupsListLowercase = groupsList.map(group => group.toLowerCase())
|
||||||
|
|
||||||
|
// Check if any of the target roles exist in the groups list
|
||||||
|
const containsTargetRole = targetRoles.some(role => groupsListLowercase.includes(role.toLowerCase()))
|
||||||
|
|
||||||
|
if (!containsTargetRole) {
|
||||||
|
Logger.info(`[Auth] openid callback: Denying access because neither admin nor user or guest is included is inside the group claim. Groups found: `, groupsList)
|
||||||
|
return done(null, null, failureMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const advancedPermsClaimName = Database.serverSettings.authOpenIDAdvancedPermsClaim
|
||||||
|
if (advancedPermsClaimName && !userinfo[advancedPermsClaimName]) {
|
||||||
|
Logger.error(`[Auth] openid callback invalid: Advanced perms claim ${advancedPermsClaimName} configured, but not found or empty in userinfo`)
|
||||||
|
return done(null, null, failureMessage)
|
||||||
|
}
|
||||||
|
|
||||||
// First check for matching user by sub
|
// First check for matching user by sub
|
||||||
let user = await Database.userModel.getUserByOpenIDSub(userinfo.sub)
|
let user = await Database.userModel.getUserByOpenIDSub(userinfo.sub)
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@ -157,6 +186,43 @@ class Auth {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set user group if name of groups claim is configured
|
||||||
|
if (groupClaimName) {
|
||||||
|
const groupsList = userinfo[groupClaimName] ? userinfo[groupClaimName].map(group => group.toLowerCase()) : []
|
||||||
|
const rolesInOrderOfPriority = ['admin', 'user', 'guest']
|
||||||
|
|
||||||
|
let userType = null
|
||||||
|
|
||||||
|
for (let role of rolesInOrderOfPriority) {
|
||||||
|
if (groupsList.includes(role)) {
|
||||||
|
userType = role // This will override with the highest priority role found
|
||||||
|
break // Stop searching once the highest priority role is found
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actually already checked above, but just to be sure
|
||||||
|
if (!userType) {
|
||||||
|
Logger.error(`[Auth] openid callback: Denying access because neither admin nor user or guest is included is inside the group claim. Groups found: `, groupsList)
|
||||||
|
return done(null, null, failureMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.debug(`[Auth] openid callback: Setting user ${user.username} type to ${userType}`)
|
||||||
|
user.type = userType
|
||||||
|
await Database.userModel.updateFromOld(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (advancedPermsClaimName) {
|
||||||
|
try {
|
||||||
|
Logger.debug(`[Auth] openid callback: Updating advanced perms for user ${user.username} to ${JSON.stringify(userinfo[advancedPermsClaimName])}`)
|
||||||
|
|
||||||
|
user.updatePermissionsFromExternalJSON(userinfo[advancedPermsClaimName])
|
||||||
|
await Database.userModel.updateFromOld(user)
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(`[Auth] openid callback: Error updating advanced perms for user, error: `, error)
|
||||||
|
return done(null, null, failureMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// We also have to save the id_token for later (used for logout) because we cannot set cookies here
|
// We also have to save the id_token for later (used for logout) because we cannot set cookies here
|
||||||
user.openid_id_token = tokenset.id_token
|
user.openid_id_token = tokenset.id_token
|
||||||
|
|
||||||
@ -334,10 +400,19 @@ class Auth {
|
|||||||
sso_redirect_uri: oidcStrategy._params.redirect_uri // Save the redirect_uri (for the SSO Provider) for the callback
|
sso_redirect_uri: oidcStrategy._params.redirect_uri // Save the redirect_uri (for the SSO Provider) for the callback
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var scope = 'openid profile email'
|
||||||
|
if (global.ServerSettings.authOpenIDGroupClaim) {
|
||||||
|
scope += ' ' + global.ServerSettings.authOpenIDGroupClaim
|
||||||
|
}
|
||||||
|
if (global.ServerSettings.authOpenIDAdvancedPermsClaim) {
|
||||||
|
scope += ' ' + global.ServerSettings.authOpenIDAdvancedPermsClaim
|
||||||
|
}
|
||||||
|
|
||||||
const authorizationUrl = client.authorizationUrl({
|
const authorizationUrl = client.authorizationUrl({
|
||||||
...oidcStrategy._params,
|
...oidcStrategy._params,
|
||||||
state: state,
|
state: state,
|
||||||
response_type: 'code',
|
response_type: 'code',
|
||||||
|
scope: scope,
|
||||||
code_challenge,
|
code_challenge,
|
||||||
code_challenge_method
|
code_challenge_method
|
||||||
})
|
})
|
||||||
@ -424,12 +499,12 @@ class Auth {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleAuthError(isMobile, errorCode, errorMessage, logMessage, response) {
|
function handleAuthError(isMobile, errorCode, errorMessage, logMessage, response) {
|
||||||
Logger.error(logMessage)
|
Logger.error(JSON.stringify(logMessage, null, 2))
|
||||||
if (response) {
|
if (response) {
|
||||||
// Depending on the error, it can also have a body
|
// Depending on the error, it can also have a body
|
||||||
// We also log the request header the passport plugin sents for the URL
|
// We also log the request header the passport plugin sents for the URL
|
||||||
const header = response.req?._header.replace(/Authorization: [^\r\n]*/i, 'Authorization: REDACTED')
|
const header = response.req?._header.replace(/Authorization: [^\r\n]*/i, 'Authorization: REDACTED')
|
||||||
Logger.debug(header + '\n' + response.body?.toString() + '\n' + JSON.stringify(response.body, null, 2))
|
Logger.debug(header + '\n' + JSON.stringify(response.body, null, 2))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
const packageJson = require('../../../package.json')
|
const packageJson = require('../../../package.json')
|
||||||
const { BookshelfView } = require('../../utils/constants')
|
const { BookshelfView } = require('../../utils/constants')
|
||||||
const Logger = require('../../Logger')
|
const Logger = require('../../Logger')
|
||||||
|
const User = require('../user/User')
|
||||||
|
|
||||||
class ServerSettings {
|
class ServerSettings {
|
||||||
constructor(settings) {
|
constructor(settings) {
|
||||||
@ -72,6 +73,8 @@ class ServerSettings {
|
|||||||
this.authOpenIDAutoRegister = false
|
this.authOpenIDAutoRegister = false
|
||||||
this.authOpenIDMatchExistingBy = null
|
this.authOpenIDMatchExistingBy = null
|
||||||
this.authOpenIDMobileRedirectURIs = ['audiobookshelf://oauth']
|
this.authOpenIDMobileRedirectURIs = ['audiobookshelf://oauth']
|
||||||
|
this.authOpenIDGroupClaim = ''
|
||||||
|
this.authOpenIDAdvancedPermsClaim = ''
|
||||||
|
|
||||||
if (settings) {
|
if (settings) {
|
||||||
this.construct(settings)
|
this.construct(settings)
|
||||||
@ -129,6 +132,8 @@ class ServerSettings {
|
|||||||
this.authOpenIDAutoRegister = !!settings.authOpenIDAutoRegister
|
this.authOpenIDAutoRegister = !!settings.authOpenIDAutoRegister
|
||||||
this.authOpenIDMatchExistingBy = settings.authOpenIDMatchExistingBy || null
|
this.authOpenIDMatchExistingBy = settings.authOpenIDMatchExistingBy || null
|
||||||
this.authOpenIDMobileRedirectURIs = settings.authOpenIDMobileRedirectURIs || ['audiobookshelf://oauth']
|
this.authOpenIDMobileRedirectURIs = settings.authOpenIDMobileRedirectURIs || ['audiobookshelf://oauth']
|
||||||
|
this.authOpenIDGroupClaim = settings.authOpenIDGroupClaim || ''
|
||||||
|
this.authOpenIDAdvancedPermsClaim = settings.authOpenIDAdvancedPermsClaim || ''
|
||||||
|
|
||||||
if (!Array.isArray(this.authActiveAuthMethods)) {
|
if (!Array.isArray(this.authActiveAuthMethods)) {
|
||||||
this.authActiveAuthMethods = ['local']
|
this.authActiveAuthMethods = ['local']
|
||||||
@ -216,7 +221,9 @@ class ServerSettings {
|
|||||||
authOpenIDAutoLaunch: this.authOpenIDAutoLaunch,
|
authOpenIDAutoLaunch: this.authOpenIDAutoLaunch,
|
||||||
authOpenIDAutoRegister: this.authOpenIDAutoRegister,
|
authOpenIDAutoRegister: this.authOpenIDAutoRegister,
|
||||||
authOpenIDMatchExistingBy: this.authOpenIDMatchExistingBy,
|
authOpenIDMatchExistingBy: this.authOpenIDMatchExistingBy,
|
||||||
authOpenIDMobileRedirectURIs: this.authOpenIDMobileRedirectURIs // Do not return to client
|
authOpenIDMobileRedirectURIs: this.authOpenIDMobileRedirectURIs, // Do not return to client
|
||||||
|
authOpenIDGroupClaim: this.authOpenIDGroupClaim, // Do not return to client
|
||||||
|
authOpenIDAdvancedPermsClaim: this.authOpenIDAdvancedPermsClaim // Do not return to client
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -226,6 +233,8 @@ class ServerSettings {
|
|||||||
delete json.authOpenIDClientID
|
delete json.authOpenIDClientID
|
||||||
delete json.authOpenIDClientSecret
|
delete json.authOpenIDClientSecret
|
||||||
delete json.authOpenIDMobileRedirectURIs
|
delete json.authOpenIDMobileRedirectURIs
|
||||||
|
delete json.authOpenIDGroupClaim
|
||||||
|
delete json.authOpenIDAdvancedPermsClaim
|
||||||
return json
|
return json
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -262,7 +271,11 @@ class ServerSettings {
|
|||||||
authOpenIDAutoLaunch: this.authOpenIDAutoLaunch,
|
authOpenIDAutoLaunch: this.authOpenIDAutoLaunch,
|
||||||
authOpenIDAutoRegister: this.authOpenIDAutoRegister,
|
authOpenIDAutoRegister: this.authOpenIDAutoRegister,
|
||||||
authOpenIDMatchExistingBy: this.authOpenIDMatchExistingBy,
|
authOpenIDMatchExistingBy: this.authOpenIDMatchExistingBy,
|
||||||
authOpenIDMobileRedirectURIs: this.authOpenIDMobileRedirectURIs // Do not return to client
|
authOpenIDMobileRedirectURIs: this.authOpenIDMobileRedirectURIs, // Do not return to client
|
||||||
|
authOpenIDGroupClaim: this.authOpenIDGroupClaim, // Do not return to client
|
||||||
|
authOpenIDAdvancedPermsClaim: this.authOpenIDAdvancedPermsClaim, // Do not return to client
|
||||||
|
|
||||||
|
authOpenIDSamplePermissions: User.getSampleAbsPermissions()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -268,6 +268,78 @@ class User {
|
|||||||
return hasUpdates
|
return hasUpdates
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// List of expected permission properties from the client
|
||||||
|
static permissionMapping = {
|
||||||
|
canDownload: 'download',
|
||||||
|
canUpload: 'upload',
|
||||||
|
canDelete: 'delete',
|
||||||
|
canUpdate: 'update',
|
||||||
|
canAccessExplicitContent: 'accessExplicitContent',
|
||||||
|
canAccessAllLibraries: 'accessAllLibraries',
|
||||||
|
canAccessAllTags: 'accessAllTags',
|
||||||
|
tagsAreBlacklist: 'selectedTagsNotAccessible',
|
||||||
|
// Direct mapping for array-based permissions
|
||||||
|
allowedLibraries: 'librariesAccessible',
|
||||||
|
allowedTags: 'itemTagsSelected',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update user from external JSON
|
||||||
|
*
|
||||||
|
* @param {object} absPermissions JSON containg user permissions
|
||||||
|
*/
|
||||||
|
updatePermissionsFromExternalJSON(absPermissions) {
|
||||||
|
// Initialize all permissions to false first
|
||||||
|
Object.keys(User.permissionMapping).forEach(mappingKey => {
|
||||||
|
const userPermKey = User.permissionMapping[mappingKey];
|
||||||
|
if (typeof this.permissions[userPermKey] === 'boolean') {
|
||||||
|
this.permissions[userPermKey] = false; // Default to false for boolean permissions
|
||||||
|
} else {
|
||||||
|
this[userPermKey] = []; // Default to empty array for other properties
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.keys(absPermissions).forEach(absKey => {
|
||||||
|
const userPermKey = User.permissionMapping[absKey]
|
||||||
|
if (!userPermKey) {
|
||||||
|
throw new Error(`Unexpected permission property: ${absKey}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the user's permissions based on absPermissions
|
||||||
|
this.permissions[userPermKey] = absPermissions[absKey]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle allowedLibraries and allowedTags separately if needed
|
||||||
|
if (absPermissions.allowedLibraries) {
|
||||||
|
this.librariesAccessible = absPermissions.allowedLibraries
|
||||||
|
}
|
||||||
|
if (absPermissions.allowedTags) {
|
||||||
|
this.itemTagsSelected = absPermissions.allowedTags
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a sample to show how a JSON for updatePermissionsFromExternalJSON should look like
|
||||||
|
*
|
||||||
|
* @returns JSON string
|
||||||
|
*/
|
||||||
|
static getSampleAbsPermissions() {
|
||||||
|
// Start with a template object where all permissions are false for simplicity
|
||||||
|
const samplePermissions = Object.keys(User.permissionMapping).reduce((acc, key) => {
|
||||||
|
// For array-based permissions, provide a sample array
|
||||||
|
if (key === 'allowedLibraries') {
|
||||||
|
acc[key] = [`ExampleLibrary`, `AnotherLibrary`];
|
||||||
|
} else if (key === 'allowedTags') {
|
||||||
|
acc[key] = [`ExampleTag`, `AnotherTag`, `ThirdTag`];
|
||||||
|
} else {
|
||||||
|
acc[key] = false;
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
return JSON.stringify(samplePermissions, null, 2); // Pretty print the JSON
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get first available library id for user
|
* Get first available library id for user
|
||||||
*
|
*
|
||||||
|
Loading…
Reference in New Issue
Block a user