diff --git a/src/resources/lang/en/ui.php b/src/resources/lang/en/ui.php index 3956790b..24cc197b 100644 --- a/src/resources/lang/en/ui.php +++ b/src/resources/lang/en/ui.php @@ -1,533 +1,534 @@ [ 'faq' => "FAQ", ], 'btn' => [ 'add' => "Add", 'accept' => "Accept", 'back' => "Back", 'cancel' => "Cancel", 'close' => "Close", 'continue' => "Continue", 'copy' => "Copy", 'delete' => "Delete", 'deny' => "Deny", 'download' => "Download", 'edit' => "Edit", 'file' => "Choose file...", 'moreinfo' => "More information", 'refresh' => "Refresh", 'reset' => "Reset", 'resend' => "Resend", 'save' => "Save", 'search' => "Search", 'share' => "Share", 'signup' => "Sign Up", 'submit' => "Submit", 'suspend' => "Suspend", 'unsuspend' => "Unsuspend", 'verify' => "Verify", ], 'companion' => [ 'title' => "Companion App", 'name' => "Name", 'description' => "Use the Companion App on your mobile phone for advanced two factor authentication.", 'pair-new' => "Pair new device", 'paired' => "Paired devices", 'pairing-instructions' => "Pair a new device using the following QR-Code:", 'deviceid' => "Device ID", 'list-empty' => "There are currently no devices", 'delete' => "Remove devices", 'remove-devices' => "Remove Devices", 'remove-devices-text' => "Do you really want to remove all devices permanently?" . " Please note that this action cannot be undone, and you can only remove all devices together." . " You may pair devices you would like to keep individually again.", ], 'dashboard' => [ 'beta' => "beta", 'distlists' => "Distribution lists", 'chat' => "Video chat", 'companion' => "Companion app", 'domains' => "Domains", 'files' => "Files", 'invitations' => "Invitations", 'profile' => "Your profile", 'resources' => "Resources", 'settings' => "Settings", 'shared-folders' => "Shared folders", 'users' => "User accounts", 'wallet' => "Wallet", 'webmail' => "Webmail", 'stats' => "Stats", ], 'distlist' => [ 'list-title' => "Distribution list | Distribution lists", 'create' => "Create list", 'delete' => "Delete list", 'email' => "Email", 'list-empty' => "There are no distribution lists in this account.", 'name' => "Name", 'new' => "New distribution list", 'recipients' => "Recipients", 'sender-policy' => "Sender Access List", 'sender-policy-text' => "With this list you can specify who can send mail to the distribution list." . " You can put a complete email address (jane@kolab.org), domain (kolab.org) or suffix (.org) that the sender email address is compared to." . " If the list is empty, mail from anyone is allowed.", ], 'domain' => [ 'delete' => "Delete domain", 'delete-domain' => "Delete {domain}", 'delete-text' => "Do you really want to delete this domain permanently?" . " This is only possible if there are no users, aliases or other objects in this domain." . " Please note that this action cannot be undone.", 'dns-verify' => "Domain DNS verification sample:", 'dns-config' => "Domain DNS configuration sample:", 'list-empty' => "There are no domains in this account.", 'namespace' => "Namespace", 'spf-whitelist' => "SPF Whitelist", 'spf-whitelist-text' => "The Sender Policy Framework allows a sender domain to disclose, through DNS, " . "which systems are allowed to send emails with an envelope sender address within said domain.", 'spf-whitelist-ex' => "Here you can specify a list of allowed servers, for example: .ess.barracuda.com.", 'verify' => "Domain verification", 'verify-intro' => "In order to confirm that you're the actual holder of the domain, we need to run a verification process before finally activating it for email delivery.", 'verify-dns' => "The domain must have one of the following entries in DNS:", 'verify-dns-txt' => "TXT entry with value:", 'verify-dns-cname' => "or CNAME entry:", 'verify-outro' => "When this is done press the button below to start the verification.", 'verify-sample' => "Here's a sample zone file for your domain:", 'config' => "Domain configuration", 'config-intro' => "In order to let {app} receive email traffic for your domain you need to adjust the DNS settings, more precisely the MX entries, accordingly.", 'config-sample' => "Edit your domain's zone file and replace existing MX entries with the following values:", 'config-hint' => "If you don't know how to set DNS entries for your domain, please contact the registration service where you registered the domain or your web hosting provider.", 'create' => "Create domain", 'new' => "New domain", ], 'error' => [ '400' => "Bad request", '401' => "Unauthorized", '403' => "Access denied", '404' => "Not found", '405' => "Method not allowed", '500' => "Internal server error", 'unknown' => "Unknown Error", 'server' => "Server Error", 'form' => "Form validation error", ], 'file' => [ 'create' => "Create file", 'delete' => "Delete file", 'list-empty' => "There are no files in this account.", 'mimetype' => "Mimetype", 'mtime' => "Modified", 'new' => "New file", 'search' => "File name", 'sharing' => "Sharing", 'sharing-links-text' => "You can share the file with other users by giving them read-only access " . "to the file via a unique link.", ], 'form' => [ 'acl' => "Access rights", 'acl-full' => "All", 'acl-read-only' => "Read-only", 'acl-read-write' => "Read-write", 'amount' => "Amount", 'anyone' => "Anyone", 'code' => "Confirmation Code", 'config' => "Configuration", 'date' => "Date", 'description' => "Description", 'details' => "Details", 'disabled' => "disabled", 'domain' => "Domain", 'email' => "Email Address", 'emails' => "Email Addresses", 'enabled' => "enabled", 'firstname' => "First Name", 'general' => "General", 'geolocation' => "Your current location: {location}", 'lastname' => "Last Name", 'name' => "Name", 'months' => "months", 'none' => "none", 'norestrictions' => "No restrictions", 'or' => "or", 'password' => "Password", 'password-confirm' => "Confirm Password", 'phone' => "Phone", 'selectcountries' => "Select countries", 'settings' => "Settings", 'shared-folder' => "Shared Folder", 'size' => "Size", 'status' => "Status", 'subscriptions' => "Subscriptions", 'surname' => "Surname", 'type' => "Type", 'unknown' => "unknown", 'user' => "User", 'primary-email' => "Primary Email", 'id' => "ID", 'created' => "Created", 'deleted' => "Deleted", ], 'invitation' => [ 'create' => "Create invite(s)", 'create-title' => "Invite for a signup", 'create-email' => "Enter an email address of the person you want to invite.", 'create-csv' => "To send multiple invitations at once, provide a CSV (comma separated) file, or alternatively a plain-text file, containing one email address per line.", 'list-empty' => "There are no invitations in the database.", 'title' => "Signup invitations", 'search' => "Email address or domain", 'send' => "Send invite(s)", 'status-completed' => "User signed up", 'status-failed' => "Sending failed", 'status-sent' => "Sent", 'status-new' => "Not sent yet", ], 'lang' => [ 'en' => "English", 'de' => "German", 'fr' => "French", 'it' => "Italian", ], 'login' => [ '2fa' => "Second factor code", '2fa_desc' => "Second factor code is optional for users with no 2-Factor Authentication setup.", 'forgot_password' => "Forgot password?", 'header' => "Please sign in", 'sign_in' => "Sign in", 'webmail' => "Webmail" ], 'meet' => [ // Room options dialog 'options' => "Room options", 'password' => "Password", 'password-none' => "none", 'password-clear' => "Clear password", 'password-set' => "Set password", 'password-text' => "You can add a password to your meeting. Participants will have to provide the password before they are allowed to join the meeting.", 'lock' => "Locked room", 'lock-text' => "When the room is locked participants have to be approved by a moderator before they could join the meeting.", 'nomedia' => "Subscribers only", 'nomedia-text' => "Forces all participants to join as subscribers (with camera and microphone turned off)." . " Moderators will be able to promote them to publishers throughout the session.", // Room menu 'partcnt' => "Number of participants", 'menu-audio-mute' => "Mute audio", 'menu-audio-unmute' => "Unmute audio", 'menu-video-mute' => "Mute video", 'menu-video-unmute' => "Unmute video", 'menu-screen' => "Share screen", 'menu-hand-lower' => "Lower hand", 'menu-hand-raise' => "Raise hand", 'menu-channel' => "Interpreted language channel", 'menu-chat' => "Chat", 'menu-fullscreen' => "Full screen", 'menu-fullscreen-exit' => "Exit full screen", 'menu-leave' => "Leave session", // Room setup screen 'setup-title' => "Set up your session", 'mic' => "Microphone", 'cam' => "Camera", 'nick' => "Nickname", 'nick-placeholder' => "Your name", 'join' => "JOIN", 'joinnow' => "JOIN NOW", 'imaowner' => "I'm the owner", // Room 'qa' => "Q & A", 'leave-title' => "Room closed", 'leave-body' => "The session has been closed by the room owner.", 'media-title' => "Media setup", 'join-request' => "Join request", 'join-requested' => "{user} requested to join.", // Status messages 'status-init' => "Checking the room...", 'status-323' => "The room is closed. Please, wait for the owner to start the session.", 'status-324' => "The room is closed. It will be open for others after you join.", 'status-325' => "The room is ready. Please, provide a valid password.", 'status-326' => "The room is locked. Please, enter your name and try again.", 'status-327' => "Waiting for permission to join the room.", 'status-404' => "The room does not exist.", 'status-429' => "Too many requests. Please, wait.", 'status-500' => "Failed to connect to the room. Server error.", // Other menus 'media-setup' => "Media setup", 'perm' => "Permissions", 'perm-av' => "Audio & Video publishing", 'perm-mod' => "Moderation", 'lang-int' => "Language interpreter", 'menu-options' => "Options", ], 'menu' => [ 'cockpit' => "Cockpit", 'login' => "Login", 'logout' => "Logout", 'signup' => "Signup", 'toggle' => "Toggle navigation", ], 'msg' => [ 'initializing' => "Initializing...", 'loading' => "Loading...", 'loading-failed' => "Failed to load data.", 'notfound' => "Resource not found.", 'info' => "Information", 'error' => "Error", 'uploading' => "Uploading...", 'warning' => "Warning", 'success' => "Success", ], 'nav' => [ 'more' => "Load more", 'step' => "Step {i}/{n}", ], 'password' => [ 'link-invalid' => "The password reset code is expired or invalid.", 'reset' => "Password Reset", 'reset-step1' => "Enter your email address to reset your password.", 'reset-step1-hint' => "You may need to check your spam folder or unblock {email}.", 'reset-step2' => "We sent out a confirmation code to your external email address." . " Enter the code we sent you, or click the link in the message.", ], 'resource' => [ 'create' => "Create resource", 'delete' => "Delete resource", 'invitation-policy' => "Invitation policy", 'invitation-policy-text' => "Event invitations for a resource are normally accepted automatically" . " if there is no conflicting event on the requested time slot. Invitation policy allows" . " for rejecting such requests or to require a manual acceptance from a specified user.", 'ipolicy-manual' => "Manual (tentative)", 'ipolicy-accept' => "Accept", 'ipolicy-reject' => "Reject", 'list-title' => "Resource | Resources", 'list-empty' => "There are no resources in this account.", 'new' => "New resource", ], 'room' => [ 'create' => "Create room", 'delete' => "Delete room", + 'copy-location' => "Copy room location", 'description-hint' => "This is an optional short description for the room, so you can find it more easily on the list.", 'goto' => "Enter the room", 'list-empty' => "There are no conference rooms in this account.", 'list-empty-nocontroller' => "Do you need a room? Ask your account owner to create one and share it with you.", 'list-title' => "Voice & video conferencing rooms", 'moderators' => "Moderators", 'moderators-text' => "You can share your room with other users. They will become the room moderators with all moderator powers and ability to open the room without your presence.", 'new' => "New room", 'new-hint' => "We'll generate a unique name for the room that will then allow you to access the room.", 'title' => "Room: {name}", 'url' => "You can access the room at the URL below. Use this URL to invite people to join you. This room is only open when you (or another room moderator) is in attendance.", ], 'settings' => [ 'password-policy' => "Password Policy", 'password-retention' => "Password Retention", 'password-max-age' => "Require a password change every", ], 'shf' => [ 'aliases-none' => "This shared folder has no email aliases.", 'create' => "Create folder", 'delete' => "Delete folder", 'acl-text' => "Defines user permissions to access the shared folder.", 'list-title' => "Shared folder | Shared folders", 'list-empty' => "There are no shared folders in this account.", 'new' => "New shared folder", 'type-mail' => "Mail", 'type-event' => "Calendar", 'type-contact' => "Address Book", 'type-task' => "Tasks", 'type-note' => "Notes", 'type-file' => "Files", ], 'signup' => [ 'email' => "Existing Email Address", 'login' => "Login", 'title' => "Sign Up", 'step1' => "Sign up to start your free month.", 'step2' => "We sent out a confirmation code to your email address. Enter the code we sent you, or click the link in the message.", 'step3' => "Create your Kolab identity (you can choose additional addresses later).", 'voucher' => "Voucher Code", ], 'status' => [ 'prepare-account' => "We are preparing your account.", 'prepare-domain' => "We are preparing the domain.", 'prepare-distlist' => "We are preparing the distribution list.", 'prepare-resource' => "We are preparing the resource.", 'prepare-shared-folder' => "We are preparing the shared folder.", 'prepare-user' => "We are preparing the user account.", 'prepare-hint' => "Some features may be missing or readonly at the moment.", 'prepare-refresh' => "The process never ends? Press the \"Refresh\" button, please.", 'ready-account' => "Your account is almost ready.", 'ready-domain' => "The domain is almost ready.", 'ready-distlist' => "The distribution list is almost ready.", 'ready-resource' => "The resource is almost ready.", 'ready-shared-folder' => "The shared-folder is almost ready.", 'ready-user' => "The user account is almost ready.", 'verify' => "Verify your domain to finish the setup process.", 'verify-domain' => "Verify domain", 'degraded' => "Degraded", 'deleted' => "Deleted", 'suspended' => "Suspended", 'notready' => "Not Ready", 'active' => "Active", ], 'support' => [ 'title' => "Contact Support", 'id' => "Customer number or email address you have with us", 'id-pl' => "e.g. 12345678 or john@kolab.org", 'id-hint' => "Leave blank if you are not a customer yet", 'name' => "Name", 'name-pl' => "how we should call you in our reply", 'email' => "Working email address", 'email-pl' => "make sure we can reach you at this address", 'summary' => "Issue Summary", 'summary-pl' => "one sentence that summarizes your issue", 'expl' => "Issue Explanation", ], 'user' => [ '2fa-hint1' => "This will remove 2-Factor Authentication entitlement as well as the user-configured factors.", '2fa-hint2' => "Please, make sure to confirm the user identity properly.", 'add-beta' => "Enable beta program", 'address' => "Address", 'aliases' => "Aliases", 'aliases-none' => "This user has no email aliases.", 'add-bonus' => "Add bonus", 'add-bonus-title' => "Add a bonus to the wallet", 'add-penalty' => "Add penalty", 'add-penalty-title' => "Add a penalty to the wallet", 'auto-payment' => "Auto-payment", 'auto-payment-text' => "Fill up by {amount} when under {balance} using {method}", 'country' => "Country", 'create' => "Create user", 'custno' => "Customer No.", 'degraded-warning' => "The account is degraded. Some features have been disabled.", 'degraded-hint' => "Please, make a payment.", 'delete' => "Delete user", 'delete-account' => "Delete this account?", 'delete-email' => "Delete {email}", 'delete-text' => "Do you really want to delete this user permanently?" . " This will delete all account data and withdraw the permission to access the email account." . " Please note that this action cannot be undone.", 'discount' => "Discount", 'discount-hint' => "applied discount", 'discount-title' => "Account discount", 'distlists' => "Distribution lists", 'domains' => "Domains", 'ext-email' => "External Email", 'email-aliases' => "Email Aliases", 'finances' => "Finances", 'geolimit' => "Geo-lockin", 'geolimit-text' => "Defines a list of locations that are allowed for logon. You will not be able to login from a country that is not listed here.", 'greylisting' => "Greylisting", 'greylisting-text' => "Greylisting is a method of defending users against spam. Any incoming mail from an unrecognized sender " . "is temporarily rejected. The originating server should try again after a delay. " . "This time the email will be accepted. Spammers usually do not reattempt mail delivery.", 'list-title' => "User accounts", 'list-empty' => "There are no users in this account.", 'managed-by' => "Managed by", 'new' => "New user account", 'org' => "Organization", 'package' => "Package", 'pass-input' => "Enter password", 'pass-link' => "Set via link", 'pass-link-label' => "Link:", 'pass-link-hint' => "Press Submit to activate the link", 'passwordpolicy' => "Password Policy", 'price' => "Price", 'profile-title' => "Your profile", 'profile-delete' => "Delete account", 'profile-delete-title' => "Delete this account?", 'profile-delete-text1' => "This will delete the account as well as all domains, users and aliases associated with this account.", 'profile-delete-warning' => "This operation is irreversible", 'profile-delete-text2' => "As you will not be able to recover anything after this point, please make sure that you have migrated all data before proceeding.", 'profile-delete-support' => "As we always strive to improve, we would like to ask for 2 minutes of your time. " . "The best tool for improvement is feedback from users, and we would like to ask " . "for a few words about your reasons for leaving our service. Please send your feedback to {email}.", 'profile-delete-contact' => "Also feel free to contact {app} Support with any questions or concerns that you may have in this context.", 'reset-2fa' => "Reset 2-Factor Auth", 'reset-2fa-title' => "2-Factor Authentication Reset", 'resources' => "Resources", 'title' => "User account", 'search' => "User email address or name", 'search-pl' => "User ID, email or domain", 'skureq' => "{sku} requires {list}.", 'subscription' => "Subscription", 'subscriptions-none' => "This user has no subscriptions.", 'users' => "Users", ], 'wallet' => [ 'add-credit' => "Add credit", 'auto-payment-cancel' => "Cancel auto-payment", 'auto-payment-change' => "Change auto-payment", 'auto-payment-failed' => "The setup of automatic payments failed. Restart the process to enable automatic top-ups.", 'auto-payment-hint' => "Here is how it works: Every time your account runs low, we will charge your preferred payment method for an amount you choose." . " You can cancel or change the auto-payment option at any time.", 'auto-payment-setup' => "Set up auto-payment", 'auto-payment-disabled' => "The configured auto-payment has been disabled. Top up your wallet or raise the auto-payment amount.", 'auto-payment-info' => "Auto-payment is set to fill up your account by {amount} every time your account balance gets under {balance}.", 'auto-payment-inprogress' => "The setup of the automatic payment is still in progress.", 'auto-payment-next' => "Next, you will be redirected to the checkout page, where you can provide your credit card details.", 'auto-payment-disabled-next' => "The auto-payment is disabled. Immediately after you submit new settings we'll enable it and attempt to top up your wallet.", 'auto-payment-update' => "Update auto-payment", 'banktransfer-hint' => "Please note that a bank transfer can take several days to complete.", 'coinbase-hint' => "Here is how it works: You specify the amount by which you want to top up your wallet in {wc}." . " We will then create a charge on Coinbase for the specified amount that you can pay using Bitcoin.", 'currency-conv' => "Here is how it works: You specify the amount by which you want to top up your wallet in {wc}." . " We will then convert this to {pc}, and on the next page you will be provided with the bank-details to transfer the amount in {pc}.", 'fill-up' => "Fill up by", 'history' => "History", 'month' => "month", 'noperm' => "Only account owners can access a wallet.", 'payment-amount-hint' => "Choose the amount by which you want to top up your wallet.", 'payment-method' => "Method of payment: {method}", 'payment-warning' => "You will be charged for {price}.", 'pending-payments' => "Pending Payments", 'pending-payments-warning' => "You have payments that are still in progress. See the \"Pending Payments\" tab below.", 'pending-payments-none' => "There are no pending payments for this account.", 'receipts' => "Receipts", 'receipts-hint' => "Here you can download receipts (in PDF format) for payments in specified period. Select the period and press the Download button.", 'receipts-none' => "There are no receipts for payments in this account. Please, note that you can download receipts after the month ends.", 'title' => "Account balance", 'top-up' => "Top up your wallet", 'transactions' => "Transactions", 'transactions-none' => "There are no transactions for this account.", 'when-below' => "when account balance is below", ], ]; diff --git a/src/resources/themes/app.scss b/src/resources/themes/app.scss index d28b26a4..a3f6ab71 100644 --- a/src/resources/themes/app.scss +++ b/src/resources/themes/app.scss @@ -1,537 +1,541 @@ html, body, body > .outer-container { height: 100%; } #app { display: flex; flex-direction: column; min-height: 100%; overflow: hidden; & > nav { flex-shrink: 0; z-index: 12; } & > div.container { flex-grow: 1; margin-top: 2rem; margin-bottom: 2rem; } & > .filler { flex-grow: 1; } & > div.container + .filler { display: none; } } .error-page { position: absolute; top: 0; height: 100%; width: 100%; align-content: center; align-items: center; display: flex; flex-wrap: wrap; justify-content: center; color: #636b6f; z-index: 10; background: white; .code { text-align: right; border-right: 2px solid; font-size: 26px; padding: 0 15px; } .message { font-size: 18px; padding: 0 15px; } .hint { margin-top: 3em; text-align: center; width: 100%; } } .app-loader { background-color: $body-bg; height: 100%; width: 100%; position: absolute; top: 0; left: 0; display: flex; align-items: center; justify-content: center; z-index: 8; .spinner-border { width: 120px; height: 120px; border-width: 15px; color: #b2aa99; } &.small .spinner-border { width: 25px; height: 25px; border-width: 3px; } &.fadeOut { visibility: hidden; opacity: 0; transition: visibility 300ms linear, opacity 300ms linear; } } pre { margin: 1rem 0; padding: 1rem; background-color: $menu-bg-color; } .card-title { font-size: 1.2rem; font-weight: bold; } tfoot.table-fake-body { background-color: #f8f8f8; color: grey; text-align: center; td { vertical-align: middle; height: 8em; border: 0; } tbody:not(:empty) + & { display: none; } } table { th { white-space: nowrap; } + td .btn-link { + vertical-align: initial; + } + td.email, td.price, td.datetime, td.selection { width: 1%; white-space: nowrap; } td.buttons, th.price, td.price, th.size, td.size { width: 1%; text-align: right; white-space: nowrap; } &.form-list { margin: 0; td { border: 0; &:first-child { padding-left: 0; } &:last-child { padding-right: 0; } } button { line-height: 1; } } .btn-action { line-height: 1; padding: 0; } &.files { table-layout: fixed; td { white-space: nowrap; } td.name { overflow: hidden; text-overflow: ellipsis; } /* td.size, th.size { width: 80px; } td.mtime, th.mtime { width: 140px; @include media-breakpoint-down(sm) { display: none; } } */ td.buttons, th.buttons { width: 50px; } } } .table > :not(:first-child) { // Remove Bootstrap's 2px border border-width: 0; } .list-details { min-height: 1em; & > ul { margin: 0; padding-left: 1.2em; } } .plan-selector { .plan-header { display: flex; } .plan-ico { margin:auto; font-size: 3.8rem; color: #f1a539; border: 3px solid #f1a539; width: 6rem; height: 6rem; border-radius: 50%; } } .status-message { display: flex; align-items: center; justify-content: center; .app-loader { width: auto; position: initial; .spinner-border { color: $body-color; } } svg { font-size: 1.5em; } :first-child { margin-right: 0.4em; } } .form-separator { position: relative; margin: 1em 0; display: flex; justify-content: center; hr { border-color: #999; margin: 0; position: absolute; top: 0.75em; width: 100%; } span { background: #fff; padding: 0 1em; z-index: 1; } } .modal { .modal-dialog, .modal-content { max-height: calc(100vh - 3.5rem); } .modal-body { overflow: auto !important; } &.fullscreen { .modal-dialog { height: 100%; width: 100%; max-width: calc(100vw - 1rem); } .modal-content { height: 100%; max-height: 100% !important; } .modal-body { padding: 0; margin: 1em; overflow: hidden !important; } } } #status-box { background-color: lighten($green, 35); .progress { background-color: #fff; height: 10px; } .progress-label { font-size: 0.9em; } .progress-bar { background-color: $green; } &.process-failed { background-color: lighten($orange, 30); .progress-bar { background-color: $red; } } } @keyframes blinker { 50% { opacity: 0; } } .blinker { animation: blinker 750ms step-start infinite; } #dashboard-nav { display: flex; flex-wrap: wrap; justify-content: center; & > a { padding: 1rem; text-align: center; white-space: nowrap; margin: 0.25rem; text-decoration: none; width: 150px; &.disabled { pointer-events: none; opacity: 0.6; } // Some icons are too big, scale them down &.link-companionapp, &.link-domains, &.link-resources, &.link-settings, &.link-wallet, &.link-invitations { svg { transform: scale(0.8); } } &.link-distlists, &.link-files, &.link-shared-folders { svg { transform: scale(0.9); } } .badge { position: absolute; top: 0.5rem; right: 0.5rem; } } svg { width: 6rem; height: 6rem; margin: auto; } } #payment-method-selection { display: flex; flex-wrap: wrap; justify-content: center; & > a { padding: 1rem; text-align: center; white-space: nowrap; margin: 0.25rem; text-decoration: none; width: 150px; } svg { width: 6rem; height: 6rem; margin: auto; } .link-banktransfer svg { transform: scale(.8); } } #logon-form { flex-basis: auto; // Bootstrap issue? See logon page with width < 992 } #logon-form-footer { a:not(:first-child) { margin-left: 2em; } } // Various improvements for mobile @include media-breakpoint-down(sm) { .card, .card-footer { border: 0; } .card-body { padding: 0.5rem 0; } .nav-tabs { flex-wrap: nowrap; .nav-link { white-space: nowrap; padding: 0.5rem 0.75rem; } } #app > div.container { margin-bottom: 1rem; margin-top: 1rem; max-width: 100%; } #header-menu-navbar { padding: 0; } #dashboard-nav > a { width: 135px; } .table-sm:not(.form-list) { tbody td { padding: 0.75rem 0.5rem; svg { vertical-align: -0.175em; } & > svg { font-size: 125%; margin-right: 0.25rem; } } } .table.transactions { thead { display: none; } tbody { tr { position: relative; display: flex; flex-wrap: wrap; } td { width: auto; border: 0; padding: 0.5rem; &.datetime { width: 50%; padding-left: 0; } &.description { order: 3; width: 100%; border-bottom: 1px solid $border-color; color: $secondary; padding: 0 1.5em 0.5rem 0; margin-top: -0.25em; } &.selection { position: absolute; right: 0; border: 0; top: 1.7em; padding-right: 0; } &.price { width: 50%; padding-right: 0; } &.email { display: none; } } } } } @include media-breakpoint-down(sm) { .tab-pane > .card-body { padding: 0.5rem; } } diff --git a/src/resources/vue/Room/List.vue b/src/resources/vue/Room/List.vue index 5503bafc..19ffc377 100644 --- a/src/resources/vue/Room/List.vue +++ b/src/resources/vue/Room/List.vue @@ -1,81 +1,89 @@ diff --git a/src/tests/Browser/Meet/RoomsTest.php b/src/tests/Browser/Meet/RoomsTest.php index 9a3ca863..6a6382be 100644 --- a/src/tests/Browser/Meet/RoomsTest.php +++ b/src/tests/Browser/Meet/RoomsTest.php @@ -1,393 +1,395 @@ whereNotIn('name', ['shared', 'john'])->forceDelete(); } /** * {@inheritDoc} */ public function tearDown(): void { Room::withTrashed()->whereNotIn('name', ['shared', 'john'])->forceDelete(); $room = $this->resetTestRoom('shared', ['acl' => ['jack@kolab.org, full']]); parent::tearDown(); } /** * Test rooms page (unauthenticated) */ public function testRoomsUnauth(): void { // Test that the page requires authentication $this->browse(function (Browser $browser) { $browser->visit('/rooms') ->on(new Home()) ->submitLogon('john@kolab.org', 'simple123', false) ->on(new RoomList()); }); } /** * Test rooms list page * * @group meet */ public function testRooms(): void { $this->browse(function (Browser $browser) { $john = $this->getTestUser('john@kolab.org'); $browser->visit('/login') ->on(new Home()) ->submitLogon('john@kolab.org', 'simple123', true) ->on(new Dashboard()) ->assertSeeIn('@links a.link-chat', 'Video chat') // Test Video chat page ->click('@links a.link-chat') ->on(new RoomList()) ->whenAvailable('@table', function ($browser) { $browser->assertElementsCount('tbody tr', 2) ->assertElementsCount('thead th', 3) ->with('tbody tr:nth-child(1)', function ($browser) { $browser->assertSeeIn('td:nth-child(1) a', 'john') ->assertSeeIn('td:nth-child(2) a', "Standard room") - ->assertVisible('td.buttons button') - ->assertAttribute('td.buttons button', 'title', 'Enter the room'); + ->assertElementsCount('td.buttons button', 2) + ->assertAttribute('td.buttons button:nth-child(1)', 'title', 'Copy room location') + ->assertAttribute('td.buttons button:nth-child(2)', 'title', 'Enter the room'); }) ->with('tbody tr:nth-child(2)', function ($browser) { $browser->assertSeeIn('td:nth-child(1) a', 'shared') ->assertSeeIn('td:nth-child(2) a', "Shared room") - ->assertVisible('td.buttons button') - ->assertAttribute('td.buttons button', 'title', 'Enter the room'); + ->assertElementsCount('td.buttons button', 2) + ->assertAttribute('td.buttons button:nth-child(1)', 'title', 'Copy room location') + ->assertAttribute('td.buttons button:nth-child(2)', 'title', 'Enter the room'); }) - ->click('tbody tr:nth-child(1) button'); + ->click('tbody tr:nth-child(1) button:nth-child(2)'); }); $newWindow = collect($browser->driver->getWindowHandles())->last(); $browser->driver->switchTo()->window($newWindow); $browser->on(new RoomPage('john')) // check that entering the room skips the logon form ->assertMissing('@toolbar') ->assertMissing('@menu') ->assertMissing('@session') ->assertMissing('@chat') ->assertMissing('@login-form') ->assertVisible('@setup-form') ->assertSeeIn('@setup-status-message', "The room is closed. It will be open for others after you join.") ->assertSeeIn('@setup-button', "JOIN") ->click('@setup-button') ->waitFor('@session') ->assertMissing('@setup-form'); }); } /** * Test rooms create and edit and delete */ public function testRoomCreateAndEditAndDelete(): void { $john = $this->getTestUser('john@kolab.org'); $this->browse(function (Browser $browser) { // Test room creation $browser->visit(new RoomList()) ->assertSeeIn('button.room-new', 'Create room') ->click('button.room-new') ->on(new RoomInfo()) ->assertVisible('@intro p') ->assertElementsCount('@nav li', 1) ->assertSeeIn('@nav li a', 'General') ->with('@general form', function ($browser) { $browser->assertSeeIn('.row:nth-child(1) label', 'Description') ->assertFocused('.row:nth-child(1) input') ->assertSeeIn('.row:nth-child(2) label', 'Subscriptions') ->with(new SubscriptionSelect('@skus'), function ($browser) { $browser->assertElementsCount('tbody tr', 2) ->assertSubscription( 0, "Standard conference room", "Audio & video conference room", "0,00 CHF/month" ) ->assertSubscriptionState(0, true) ->assertSubscription( 1, "Group conference room", "Shareable audio & video conference room", "0,00 CHF/month" ) ->assertSubscriptionState(1, false) ->clickSubscription(1) ->assertSubscriptionState(0, false) ->assertSubscriptionState(1, true); }) ->type('.row:nth-child(1) input', 'test123'); }) ->click('@general button[type=submit]') ->assertToast(Toast::TYPE_SUCCESS, "Room created successfully.") ->on(new RoomList()) ->whenAvailable('@table', function ($browser) { $browser->assertElementsCount('tbody tr', 3); }); $room = Room::where('description', 'test123')->first(); $this->assertTrue($room->hasSKU('group-room')); // Test room editing $browser->click("a[href=\"/room/{$room->id}\"]") ->on(new RoomInfo()) ->assertSeeIn('.card-title', "Room: {$room->name}") ->assertVisible('@intro p') ->assertVisible("@intro a[href=\"/meet/{$room->name}\"]") ->assertElementsCount('@nav li', 2) ->assertSeeIn('@nav li:first-child a', 'General') ->with('@general form', function ($browser) { $browser->assertSeeIn('.row:nth-child(1) label', 'Description') ->assertFocused('.row:nth-child(1) input') ->type('.row:nth-child(1) input', 'test321') ->assertSeeIn('.row:nth-child(2) label', 'Subscriptions') ->with(new SubscriptionSelect('@skus'), function ($browser) { $browser->assertElementsCount('tbody tr', 2) ->assertSubscription( 0, "Standard conference room", "Audio & video conference room", "0,00 CHF/month" ) ->assertSubscriptionState(0, false) ->assertSubscription( 1, "Group conference room", "Shareable audio & video conference room", "0,00 CHF/month" ) ->assertSubscriptionState(1, true) ->clickSubscription(0) ->assertSubscriptionState(0, true) ->assertSubscriptionState(1, false); }); }) ->click('@general button[type=submit]') ->assertToast(Toast::TYPE_SUCCESS, "Room updated successfully.") ->on(new RoomList()); $room->refresh(); $this->assertSame('test321', $room->description); $this->assertFalse($room->hasSKU('group-room')); // Test room deleting $browser->visit('/room/' . $room->id) ->on(new Roominfo()) ->assertSeeIn('button.button-delete', 'Delete room') ->click('button.button-delete') ->assertToast(Toast::TYPE_SUCCESS, "Room deleted successfully.") ->on(new RoomList()) ->whenAvailable('@table', function ($browser) { $browser->assertElementsCount('tbody tr', 2); }); }); } /** * Test room settings */ public function testRoomSettings(): void { $this->browse(function (Browser $browser) { $john = $this->getTestUser('john@kolab.org'); $room = $this->getTestRoom('test', $john->wallets()->first()); // Test that there's no Moderators for non-group rooms $browser->visit('/room/' . $room->id) ->on(new RoomInfo()) ->assertSeeIn('@nav li:last-child a', 'Settings') ->click('@nav li:last-child a') ->with('@settings form', function ($browser) { $browser->assertSeeIn('.row:nth-child(1) label', 'Password') ->assertValue('.row:nth-child(1) input', '') ->assertVisible('.row:nth-child(1) .form-text') ->assertSeeIn('.row:nth-child(2) label', 'Locked room') ->assertNotChecked('.row:nth-child(2) input') ->assertVisible('.row:nth-child(2) .form-text') ->assertSeeIn('.row:nth-child(3) label', 'Subscribers only') ->assertNotChecked('.row:nth-child(3) input') ->assertVisible('.row:nth-child(3) .form-text') ->assertMissing('.row:nth-child(4)'); // no Moderators section on a standard room }); $room->forceDelete(); $room = $this->getTestRoom('test', $john->wallets()->first(), [], [], 'group-room'); // Now we can assert and change all settings $browser->visit('/room/' . $room->id) ->on(new RoomInfo()) ->assertSeeIn('@nav li:last-child a', 'Settings') ->click('@nav li:last-child a') ->with('@settings form', function ($browser) { $browser->assertSeeIn('.row:nth-child(4) label', 'Moderators') ->assertVisible('.row:nth-child(4) .form-text') ->type('#acl .input-group:first-child input', 'jack') ->click('#acl a.btn'); }) ->click('@settings button[type=submit]') ->assertToast(Toast::TYPE_ERROR, "Form validation error") ->assertSeeIn('#acl + .invalid-feedback', "The specified email address is invalid.") ->with('@settings form', function ($browser) { $browser->type('.row:nth-child(1) input', 'pass') ->click('.row:nth-child(2) input') ->click('.row:nth-child(3) input') ->click('#acl .input-group:last-child a.btn') ->type('#acl .input-group:first-child input', 'jack@kolab.org') ->click('#acl a.btn'); }) ->click('@settings button[type=submit]') ->assertToast(Toast::TYPE_SUCCESS, "Room configuration updated successfully."); $config = $room->getConfig(); $this->assertSame('pass', $config['password']); $this->assertSame(true, $config['locked']); $this->assertSame(true, $config['nomedia']); $this->assertSame(['jack@kolab.org, full'], $config['acl']); }); } /** * Test acting as a non-controller user * * @group meet */ public function testNonControllerRooms(): void { $jack = $this->getTestUser('jack@kolab.org'); $john = $this->getTestUser('john@kolab.org'); $room = $this->resetTestRoom('shared', [ 'password' => 'pass', 'locked' => true, 'nomedia' => true, 'acl' => ['jack@kolab.org, full'] ]); $this->browse(function (Browser $browser) use ($room, $jack) { $browser->visit(new Home()) ->submitLogon('jack@kolab.org', 'simple123', true) ->on(new Dashboard()) ->click('@links a.link-chat') ->on(new RoomList()) ->assertMissing('button.room-new') ->whenAvailable('@table', function ($browser) { $browser->assertElementsCount('tbody tr', 2); // one shared room, one owned room }); // the owned room $owned = $jack->rooms()->first(); $browser->visit('/room/' . $owned->id) ->on(new RoomInfo()) ->assertSeeIn('.card-title', "Room: {$owned->name}") ->assertVisible('@intro p') ->assertVisible("@intro a[href=\"/meet/{$owned->name}\"]") ->assertMissing('button.button-delete') ->assertElementsCount('@nav li', 2) ->with('@general form', function ($browser) { $browser->assertSeeIn('.row:nth-child(1) label', 'Description') ->assertFocused('.row:nth-child(1) input') ->assertSeeIn('.row:nth-child(2) label', 'Subscriptions') ->with(new SubscriptionSelect('@skus'), function ($browser) { $browser->assertElementsCount('tbody tr', 1) ->assertSubscription( 0, "Standard conference room", "Audio & video conference room", "0,00 CHF/month" ) ->assertSubscriptionState(0, true); }); }) ->click('@nav li:last-child a') ->with('@settings form', function ($browser) { $browser->assertSeeIn('.row:nth-child(1) label', 'Password') ->assertValue('.row:nth-child(1) input', '') ->assertSeeIn('.row:nth-child(2) label', 'Locked room') ->assertNotChecked('.row:nth-child(2) input') ->assertSeeIn('.row:nth-child(3) label', 'Subscribers only') ->assertNotChecked('.row:nth-child(3) input') ->assertMissing('.row:nth-child(4)'); }); // Shared room $browser->visit('/room/' . $room->id) ->on(new RoomInfo()) ->assertSeeIn('.card-title', "Room: {$room->name}") ->assertVisible('@intro p') ->assertVisible("@intro a[href=\"/meet/{$room->name}\"]") ->assertMissing('button.button-delete') ->assertElementsCount('@nav li', 1) // Test room settings ->assertSeeIn('@nav li:last-child a', 'Settings') ->with('@settings form', function ($browser) { $browser->assertSeeIn('.row:nth-child(1) label', 'Password') ->assertValue('.row:nth-child(1) input', 'pass') ->assertSeeIn('.row:nth-child(2) label', 'Locked room') ->assertChecked('.row:nth-child(2) input') ->assertSeeIn('.row:nth-child(3) label', 'Subscribers only') ->assertChecked('.row:nth-child(3) input') ->assertMissing('.row:nth-child(4)') ->type('.row:nth-child(1) input', 'pass123') ->click('.row:nth-child(2) input') ->click('.row:nth-child(3) input'); }) ->click('@settings button[type=submit]') ->assertToast(Toast::TYPE_SUCCESS, "Room configuration updated successfully."); $config = $room->getConfig(); $this->assertSame('pass123', $config['password']); $this->assertSame(false, $config['locked']); $this->assertSame(false, $config['nomedia']); $this->assertSame(['jack@kolab.org, full'], $config['acl']); $browser->click("@intro a[href=\"/meet/shared\"]") ->on(new RoomPage('shared')) // check that entering the room skips the logon form ->assertMissing('@toolbar') ->assertMissing('@menu') ->assertMissing('@session') ->assertMissing('@chat') ->assertMissing('@login-form') ->assertVisible('@setup-form') ->assertSeeIn('@setup-status-message', "The room is closed. It will be open for others after you join.") ->assertSeeIn('@setup-button', "JOIN") ->click('@setup-button') ->waitFor('@session') ->assertMissing('@setup-form') ->waitFor('a.meet-nickname svg.moderator'); }); } }