feat: Introduce a self-hosted mode, refactor email notifications to use Mailable classes with new templates, and update the project README.
This commit is contained in:
parent
af32361e89
commit
a6517f4b5b
34 changed files with 1039 additions and 265 deletions
1
.github/FUNDING.yml
vendored
Normal file
1
.github/FUNDING.yml
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
github: [trypost-it]
|
||||
65
LICENSE.md
Normal file
65
LICENSE.md
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
# TryPost License
|
||||
|
||||
## Functional Source License, Version 1.1, MIT
|
||||
|
||||
### Licensor
|
||||
|
||||
TryPost
|
||||
|
||||
### Software
|
||||
|
||||
TryPost - Social Media Scheduling Platform
|
||||
|
||||
### Use Limitation
|
||||
|
||||
You may not use the Software to provide a commercial service that allows third parties to schedule, manage, or publish content to social media platforms, where such service competes with or substitutes for TryPost or any product or service provided by Licensor.
|
||||
|
||||
### License Grant
|
||||
|
||||
Licensor hereby grants you the right to copy, modify, create derivative works, redistribute, and make non-production use of the Software. If your use of the Software does not violate the Use Limitation, Licensor hereby grants you the right to make production use of the Software.
|
||||
|
||||
### Permitted Uses
|
||||
|
||||
The following uses are expressly permitted:
|
||||
|
||||
1. **Personal Use**: Using TryPost for your own personal social media management
|
||||
2. **Internal Business Use**: Using TryPost within your organization to manage your company's social media accounts
|
||||
3. **Educational Use**: Using TryPost for learning, teaching, or research purposes
|
||||
4. **Development**: Modifying, studying, or contributing to the TryPost codebase
|
||||
5. **Self-Hosting**: Deploying TryPost on your own infrastructure for personal or internal business use
|
||||
|
||||
### Prohibited Uses
|
||||
|
||||
The following uses are expressly prohibited:
|
||||
|
||||
1. **Competing SaaS**: Offering TryPost (or a modified version) as a hosted service to third parties
|
||||
2. **White-Label Services**: Rebranding and selling TryPost as your own product
|
||||
3. **Managed Service Provider**: Operating TryPost as a service for multiple clients or customers
|
||||
|
||||
### Corollary
|
||||
|
||||
For clarity, the Use Limitation does not prohibit:
|
||||
|
||||
- Using TryPost to manage social media for your own business or clients (as a social media manager)
|
||||
- Integrating TryPost into internal tools that are not offered as a competing service
|
||||
- Contributing improvements back to the TryPost project
|
||||
|
||||
### Notice
|
||||
|
||||
You must ensure that anyone who receives a copy of any part of the Software from you also receives a copy of this License.
|
||||
|
||||
### No Other Rights
|
||||
|
||||
These terms do not grant you any right in any trademark or logo of Licensor or its affiliates.
|
||||
|
||||
### Patent
|
||||
|
||||
To the extent your use qualifies as a Permitted Use, Licensor grants you a license under any patent claims it can license that are necessarily infringed by the Software, to make, use, and distribute the Software.
|
||||
|
||||
### Disclaimer
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
---
|
||||
|
||||
Copyright (c) 2025 TryPost
|
||||
202
README.md
202
README.md
|
|
@ -1,15 +1,199 @@
|
|||
<p align="center">
|
||||
<img src="public/images/trypost/logo-dark.png" alt="TryPost" width="200">
|
||||
</p>
|
||||
|
||||
## Migration with base seed
|
||||
```sh
|
||||
php artisan migrate:fresh --seed
|
||||
<h1 align="center">TryPost</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>The open-source social media scheduling platform</strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
Schedule, manage, and publish content to all your social media accounts from one place.
|
||||
<br />
|
||||
Self-hosted. Privacy-focused. No limits.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/trypost-it/trypost/stargazers"><img src="https://img.shields.io/github/stars/trypost-it/trypost" alt="Stars"></a>
|
||||
<a href="https://github.com/trypost-it/trypost/blob/main/LICENSE.md"><img src="https://img.shields.io/badge/license-FSL-blue" alt="License"></a>
|
||||
<a href="https://github.com/trypost-it/trypost/releases"><img src="https://img.shields.io/github/v/release/trypost-it/trypost" alt="Release"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="#features">Features</a> •
|
||||
<a href="#supported-platforms">Platforms</a> •
|
||||
<a href="#quick-start">Quick Start</a> •
|
||||
<a href="#self-hosting">Self-Hosting</a> •
|
||||
<a href="#contributing">Contributing</a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## Why TryPost?
|
||||
|
||||
Tired of paying expensive monthly fees for social media scheduling tools? Want full control over your data? TryPost is the solution.
|
||||
|
||||
- **100% Open Source** - Inspect the code, contribute, make it yours
|
||||
- **Self-Hosted** - Your data stays on your servers
|
||||
- **No Limits** - Schedule unlimited posts, connect unlimited accounts
|
||||
- **Privacy First** - No tracking, no analytics, no data selling
|
||||
|
||||
## Features
|
||||
|
||||
- **Visual Calendar** - Drag and drop posts across your content calendar
|
||||
- **Post Composer** - Create and preview posts for multiple platforms at once
|
||||
- **Media Library** - Upload images and videos with automatic optimization
|
||||
- **Team Collaboration** - Invite team members with role-based permissions
|
||||
- **Workspaces** - Manage multiple brands or clients separately
|
||||
- **Smart Scheduling** - Schedule posts for optimal engagement times
|
||||
- **Multi-Platform Preview** - See exactly how your post will look on each platform
|
||||
|
||||
## Supported Platforms
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center"><img src="public/images/accounts/x.png" width="40"><br><b>X (Twitter)</b></td>
|
||||
<td align="center"><img src="public/images/accounts/linkedin.png" width="40"><br><b>LinkedIn</b></td>
|
||||
<td align="center"><img src="public/images/accounts/facebook.png" width="40"><br><b>Facebook</b></td>
|
||||
<td align="center"><img src="public/images/accounts/instagram.png" width="40"><br><b>Instagram</b></td>
|
||||
<td align="center"><img src="public/images/accounts/tiktok.png" width="40"><br><b>TikTok</b></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><img src="public/images/accounts/youtube.png" width="40"><br><b>YouTube</b></td>
|
||||
<td align="center"><img src="public/images/accounts/pinterest.png" width="40"><br><b>Pinterest</b></td>
|
||||
<td align="center"><img src="public/images/accounts/threads.png" width="40"><br><b>Threads</b></td>
|
||||
<td align="center"><img src="public/images/accounts/bluesky.png" width="40"><br><b>Bluesky</b></td>
|
||||
<td align="center"><img src="public/images/accounts/mastodon.png" width="40"><br><b>Mastodon</b></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Requirements
|
||||
|
||||
- PHP 8.2+
|
||||
- Node.js 18+
|
||||
- MySQL 8.0+ or PostgreSQL 14+
|
||||
- Redis
|
||||
- Composer
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/trypost-it/trypost.git
|
||||
cd trypost
|
||||
|
||||
# Install dependencies
|
||||
composer install
|
||||
npm install
|
||||
|
||||
# Setup environment
|
||||
cp .env.example .env
|
||||
php artisan key:generate
|
||||
|
||||
# Configure your database in .env, then run migrations
|
||||
php artisan migrate --seed
|
||||
|
||||
# Build assets
|
||||
npm run build
|
||||
|
||||
# Start the server
|
||||
php artisan serve
|
||||
```
|
||||
|
||||
# Start queue worker
|
||||
```sh
|
||||
Visit `http://localhost:8000` and create your account!
|
||||
|
||||
## Self-Hosting
|
||||
|
||||
TryPost is designed to be self-hosted. Enable self-hosted mode in your `.env`:
|
||||
|
||||
```env
|
||||
SELF_HOSTED=true
|
||||
```
|
||||
|
||||
This skips payment/subscription requirements and gives you full access.
|
||||
|
||||
### Running in Production
|
||||
|
||||
```bash
|
||||
# Start the queue worker
|
||||
php artisan horizon
|
||||
|
||||
# Or use the watcher for development
|
||||
php artisan horizon:watch
|
||||
```
|
||||
|
||||
# Share with Ngrok:
|
||||
```sh
|
||||
ngrok http --host-header=rewrite trypost.test:443
|
||||
```
|
||||
### Docker (Coming Soon)
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|-------|------------|
|
||||
| **Backend** | Laravel 12, PHP 8.4 |
|
||||
| **Frontend** | Vue 3, Inertia.js, TypeScript |
|
||||
| **Styling** | Tailwind CSS 4 |
|
||||
| **Database** | MySQL / PostgreSQL |
|
||||
| **Queue** | Redis + Laravel Horizon |
|
||||
| **Real-time** | Laravel Reverb |
|
||||
|
||||
## Roadmap
|
||||
|
||||
Check out our [public roadmap](https://github.com/orgs/trypost-it/projects/1) to see what's coming next and vote on features!
|
||||
|
||||
## Contributing
|
||||
|
||||
We love contributions! Whether it's:
|
||||
|
||||
- Bug reports
|
||||
- Feature requests
|
||||
- Documentation improvements
|
||||
- Code contributions
|
||||
|
||||
### Development Setup
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
composer install
|
||||
npm install
|
||||
|
||||
# Start development server
|
||||
npm run dev
|
||||
|
||||
# Run tests
|
||||
php artisan test
|
||||
```
|
||||
|
||||
## Community
|
||||
|
||||
- [GitHub Discussions](https://github.com/trypost-it/trypost/discussions) - Ask questions, share ideas
|
||||
- [GitHub Issues](https://github.com/trypost-it/trypost/issues) - Report bugs, request features
|
||||
|
||||
## License
|
||||
|
||||
TryPost is licensed under the [Functional Source License (FSL)](LICENSE.md).
|
||||
|
||||
**You can:**
|
||||
- Use TryPost for personal or internal business use
|
||||
- Self-host for your own social media management
|
||||
- Modify and contribute to the codebase
|
||||
|
||||
**You cannot:**
|
||||
- Offer TryPost as a competing SaaS product
|
||||
- White-label and resell TryPost
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<strong>If TryPost helps you, please give us a star!</strong>
|
||||
<br />
|
||||
<br />
|
||||
<a href="https://github.com/trypost-it/trypost">
|
||||
<img src="https://img.shields.io/github/stars/trypost-it/trypost?style=social" alt="Star on GitHub">
|
||||
</a>
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -6,26 +6,4 @@ enum Status: string
|
|||
{
|
||||
case Pending = 'pending';
|
||||
case Accepted = 'accepted';
|
||||
case Expired = 'expired';
|
||||
case Cancelled = 'cancelled';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Pending => 'Pending',
|
||||
self::Accepted => 'Accepted',
|
||||
self::Expired => 'Expired',
|
||||
self::Cancelled => 'Cancelled',
|
||||
};
|
||||
}
|
||||
|
||||
public function color(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Pending => 'yellow',
|
||||
self::Accepted => 'green',
|
||||
self::Expired => 'gray',
|
||||
self::Cancelled => 'red',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -74,12 +74,24 @@ public function step2(Request $request): Response
|
|||
}
|
||||
|
||||
/**
|
||||
* Store step 2 and redirect to Stripe checkout.
|
||||
* Store step 2 and redirect to Stripe checkout (or complete if self-hosted).
|
||||
*/
|
||||
public function storeStep2(Request $request): SymfonyResponse
|
||||
public function storeStep2(Request $request): SymfonyResponse|RedirectResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
// Skip payment for self-hosted mode
|
||||
if (config('trypost.self_hosted')) {
|
||||
$user->update([
|
||||
'setup' => Setup::Completed,
|
||||
]);
|
||||
|
||||
session()->flash('flash.banner', 'Welcome to TryPost!');
|
||||
session()->flash('flash.bannerStyle', 'success');
|
||||
|
||||
return redirect()->route('calendar');
|
||||
}
|
||||
|
||||
$user->update([
|
||||
'setup' => Setup::Subscription,
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -4,10 +4,11 @@
|
|||
|
||||
use App\Enums\UserWorkspace\Role as WorkspaceRole;
|
||||
use App\Http\Requests\StoreWorkspaceInviteRequest;
|
||||
use App\Mail\WorkspaceInvite as WorkspaceInviteMail;
|
||||
use App\Models\WorkspaceInvite;
|
||||
use App\Notifications\WorkspaceInviteNotification;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
|
|
@ -86,9 +87,12 @@ public function store(StoreWorkspaceInviteRequest $request): RedirectResponse
|
|||
'role' => $request->role ?? WorkspaceRole::Member,
|
||||
]);
|
||||
|
||||
$invite->notify(new WorkspaceInviteNotification($invite));
|
||||
Mail::to($invite->email)->send(new WorkspaceInviteMail($invite));
|
||||
|
||||
return back()->with('success', 'Invite sent successfully!');
|
||||
session()->flash('flash.banner', 'Invite sent successfully!');
|
||||
session()->flash('flash.bannerStyle', 'success');
|
||||
|
||||
return back();
|
||||
}
|
||||
|
||||
public function destroy(Request $request, WorkspaceInvite $invite): RedirectResponse
|
||||
|
|
@ -105,23 +109,65 @@ public function destroy(Request $request, WorkspaceInvite $invite): RedirectResp
|
|||
abort(404);
|
||||
}
|
||||
|
||||
$invite->cancel();
|
||||
$invite->delete();
|
||||
|
||||
return back()->with('success', 'Invite cancelled.');
|
||||
session()->flash('flash.banner', 'Invite deleted.');
|
||||
session()->flash('flash.bannerStyle', 'success');
|
||||
|
||||
return back();
|
||||
}
|
||||
|
||||
public function show(Request $request, string $token): Response|RedirectResponse
|
||||
{
|
||||
$invite = WorkspaceInvite::where('token', $token)
|
||||
->with(['workspace', 'inviter'])
|
||||
->firstOrFail();
|
||||
|
||||
if (! $invite->isPending()) {
|
||||
session()->flash('flash.banner', 'This invite is no longer valid.');
|
||||
session()->flash('flash.bannerStyle', 'danger');
|
||||
|
||||
return redirect()->route('login');
|
||||
}
|
||||
|
||||
$user = $request->user();
|
||||
|
||||
// Store token in session for after login/register
|
||||
session(['pending_invite_token' => $token]);
|
||||
|
||||
return Inertia::render('invites/Accept', [
|
||||
'invite' => [
|
||||
'id' => $invite->id,
|
||||
'token' => $invite->token,
|
||||
'email' => $invite->email,
|
||||
'role' => [
|
||||
'value' => $invite->role->value,
|
||||
'label' => $invite->role->label(),
|
||||
],
|
||||
'workspace' => [
|
||||
'id' => $invite->workspace->id,
|
||||
'name' => $invite->workspace->name,
|
||||
],
|
||||
'inviter' => [
|
||||
'id' => $invite->inviter->id,
|
||||
'name' => $invite->inviter->name,
|
||||
'email' => $invite->inviter->email,
|
||||
],
|
||||
],
|
||||
'isAuthenticated' => (bool) $user,
|
||||
'userEmail' => $user?->email,
|
||||
]);
|
||||
}
|
||||
|
||||
public function accept(Request $request, string $token): RedirectResponse
|
||||
{
|
||||
$invite = WorkspaceInvite::where('token', $token)->firstOrFail();
|
||||
|
||||
if (! $invite->isValid()) {
|
||||
if ($invite->isExpired()) {
|
||||
return redirect()->route('dashboard')
|
||||
->withErrors(['invite' => 'This invite has expired.']);
|
||||
}
|
||||
if (! $invite->isPending()) {
|
||||
session()->flash('flash.banner', 'This invite is no longer valid.');
|
||||
session()->flash('flash.bannerStyle', 'danger');
|
||||
|
||||
return redirect()->route('dashboard')
|
||||
->withErrors(['invite' => 'This invite is no longer valid.']);
|
||||
return redirect()->route('login');
|
||||
}
|
||||
|
||||
$user = $request->user();
|
||||
|
|
@ -129,25 +175,28 @@ public function accept(Request $request, string $token): RedirectResponse
|
|||
if (! $user) {
|
||||
session(['pending_invite_token' => $token]);
|
||||
|
||||
return redirect()->route('login')
|
||||
->with('message', 'Please log in to accept the invite.');
|
||||
return redirect()->route('login');
|
||||
}
|
||||
|
||||
// Clear the pending token
|
||||
session()->forget('pending_invite_token');
|
||||
|
||||
if ($invite->workspace->hasMember($user)) {
|
||||
// Switch to this workspace
|
||||
$user->switchWorkspace($invite->workspace);
|
||||
|
||||
return redirect()->route('calendar')
|
||||
->with('message', 'You are already a member of this workspace.');
|
||||
session()->flash('flash.banner', 'You are already a member of this workspace.');
|
||||
session()->flash('flash.bannerStyle', 'info');
|
||||
|
||||
return redirect()->route('calendar');
|
||||
}
|
||||
|
||||
$invite->accept($user);
|
||||
|
||||
// Switch to the new workspace
|
||||
$user->switchWorkspace($invite->workspace);
|
||||
|
||||
return redirect()->route('calendar')
|
||||
->with('success', 'You are now a member of the workspace!');
|
||||
session()->flash('flash.banner', 'You are now a member of the workspace!');
|
||||
session()->flash('flash.bannerStyle', 'success');
|
||||
|
||||
return redirect()->route('calendar');
|
||||
}
|
||||
|
||||
public function removeMember(Request $request, string $userId): RedirectResponse
|
||||
|
|
@ -166,6 +215,9 @@ public function removeMember(Request $request, string $userId): RedirectResponse
|
|||
|
||||
$workspace->members()->detach($userId);
|
||||
|
||||
return back()->with('success', 'Member removed successfully.');
|
||||
session()->flash('flash.banner', 'Member removed successfully.');
|
||||
session()->flash('flash.bannerStyle', 'success');
|
||||
|
||||
return back();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,11 @@ class EnsureSubscribed
|
|||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
// Skip subscription check for self-hosted mode
|
||||
if (config('trypost.self_hosted')) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$user = $request->user();
|
||||
|
||||
if (! $user) {
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ public function share(Request $request): array
|
|||
'flash' => $request->session()->get('flash', []),
|
||||
'env' => config('app.env'),
|
||||
'locale' => app()->getLocale(),
|
||||
'selfHosted' => config('trypost.self_hosted'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,11 @@ public function toResponse($request): Response
|
|||
{
|
||||
$user = $request->user();
|
||||
|
||||
// Check for pending invite token
|
||||
if ($token = session('pending_invite_token')) {
|
||||
return redirect()->route('invites.show', $token);
|
||||
}
|
||||
|
||||
// Determine redirect based on setup status
|
||||
$redirect = match ($user->setup) {
|
||||
Setup::Completed => route('calendar'),
|
||||
|
|
|
|||
|
|
@ -9,6 +9,11 @@ class RegisterResponse implements RegisterResponseContract
|
|||
{
|
||||
public function toResponse($request): Response
|
||||
{
|
||||
// Check for pending invite token
|
||||
if ($token = session('pending_invite_token')) {
|
||||
return redirect()->route('invites.show', $token);
|
||||
}
|
||||
|
||||
return $request->wantsJson()
|
||||
? response()->json(['two_factor' => false])
|
||||
: redirect()->route('onboarding.step1');
|
||||
|
|
|
|||
45
app/Mail/WorkspaceInvite.php
Normal file
45
app/Mail/WorkspaceInvite.php
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
<?php
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Models\WorkspaceInvite as WorkspaceInviteModel;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class WorkspaceInvite extends Mailable implements ShouldQueue
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public WorkspaceInviteModel $invite
|
||||
) {}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(
|
||||
subject: "You've been invited to join {$this->invite->workspace->name}",
|
||||
);
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(
|
||||
view: 'mail.workspace-invite',
|
||||
with: [
|
||||
'title' => "You've been invited to join {$this->invite->workspace->name}",
|
||||
'previewText' => "{$this->invite->inviter->name} has invited you to collaborate on TryPost.",
|
||||
'invite' => $this->invite,
|
||||
'url' => route('invites.show', $this->invite->token),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
public function attachments(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
|
@ -27,7 +27,6 @@ class WorkspaceInvite extends Model
|
|||
'token',
|
||||
'role',
|
||||
'status',
|
||||
'expires_at',
|
||||
'accepted_at',
|
||||
];
|
||||
|
||||
|
|
@ -39,7 +38,6 @@ protected function casts(): array
|
|||
return [
|
||||
'role' => Role::class,
|
||||
'status' => InviteStatus::class,
|
||||
'expires_at' => 'datetime',
|
||||
'accepted_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
|
@ -50,9 +48,6 @@ protected static function booted(): void
|
|||
if (empty($invite->token)) {
|
||||
$invite->token = Str::random(64);
|
||||
}
|
||||
if (empty($invite->expires_at)) {
|
||||
$invite->expires_at = now()->addDays(7);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -71,26 +66,11 @@ public function scopePending(Builder $query): Builder
|
|||
return $query->where('status', InviteStatus::Pending);
|
||||
}
|
||||
|
||||
public function scopeValid(Builder $query): Builder
|
||||
{
|
||||
return $query->pending()->where('expires_at', '>', now());
|
||||
}
|
||||
|
||||
public function isExpired(): bool
|
||||
{
|
||||
return $this->expires_at->isPast();
|
||||
}
|
||||
|
||||
public function isPending(): bool
|
||||
{
|
||||
return $this->status === InviteStatus::Pending;
|
||||
}
|
||||
|
||||
public function isValid(): bool
|
||||
{
|
||||
return $this->isPending() && ! $this->isExpired();
|
||||
}
|
||||
|
||||
public function accept(User $user): void
|
||||
{
|
||||
$this->workspace->members()->attach($user->id, [
|
||||
|
|
@ -103,11 +83,6 @@ public function accept(User $user): void
|
|||
]);
|
||||
}
|
||||
|
||||
public function cancel(): void
|
||||
{
|
||||
$this->update(['status' => InviteStatus::Cancelled]);
|
||||
}
|
||||
|
||||
public function routeNotificationForMail(): string
|
||||
{
|
||||
return $this->email;
|
||||
|
|
|
|||
|
|
@ -1,63 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use App\Models\WorkspaceInvite;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class WorkspaceInviteNotification extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
/**
|
||||
* Create a new notification instance.
|
||||
*/
|
||||
public function __construct(
|
||||
public WorkspaceInvite $invite
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get the notification's delivery channels.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['mail'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the mail representation of the notification.
|
||||
*/
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
$acceptUrl = route('invites.accept', $this->invite->token);
|
||||
|
||||
return (new MailMessage)
|
||||
->subject("Convite para o workspace {$this->invite->workspace->name}")
|
||||
->greeting('Olá!')
|
||||
->line("{$this->invite->inviter->name} convidou você para colaborar no workspace **{$this->invite->workspace->name}**.")
|
||||
->line("Você foi convidado como **{$this->invite->role->label()}**.")
|
||||
->action('Aceitar Convite', $acceptUrl)
|
||||
->line('Este convite expira em 7 dias.')
|
||||
->salutation('Obrigado por usar TryPost!');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the array representation of the notification.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(object $notifiable): array
|
||||
{
|
||||
return [
|
||||
'workspace_id' => $this->invite->workspace_id,
|
||||
'workspace_name' => $this->invite->workspace->name,
|
||||
'inviter_name' => $this->invite->inviter->name,
|
||||
'role' => $this->invite->role->value,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -135,10 +135,10 @@ protected function configureDefaults(): void
|
|||
VerifyEmail::toMailUsing(function (User $user, string $url) {
|
||||
return (new MailMessage)
|
||||
->from(config('mail.from.address'), config('mail.from.name'))
|
||||
->subject('Confirme o seu endereço de e-mail')
|
||||
->subject('Verify your email address')
|
||||
->view('mail.email-verification', [
|
||||
'title' => 'Confirme o seu endereço de e-mail',
|
||||
'previewText' => 'Por favor, confirme o seu endereço de e-mail.',
|
||||
'title' => 'Verify your email address',
|
||||
'previewText' => 'Please verify your email address.',
|
||||
'user' => $user,
|
||||
'url' => $url,
|
||||
]);
|
||||
|
|
@ -153,10 +153,10 @@ protected function configureDefaults(): void
|
|||
|
||||
return (new MailMessage)
|
||||
->from(config('mail.from.address'), config('mail.from.name'))
|
||||
->subject('Redefina sua senha')
|
||||
->subject('Reset your password')
|
||||
->view('mail.password-reset', [
|
||||
'title' => 'Redefina sua senha',
|
||||
'previewText' => 'Por favor, redefina sua senha.',
|
||||
'title' => 'Reset your password',
|
||||
'previewText' => 'Reset your password.',
|
||||
'user' => $user,
|
||||
'url' => $url,
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ private function configureViews(): void
|
|||
'canResetPassword' => Features::enabled(Features::resetPasswords()),
|
||||
'canRegister' => Features::enabled(Features::registration()),
|
||||
'status' => $request->session()->get('status'),
|
||||
'email' => $request->query('email'),
|
||||
]));
|
||||
|
||||
Fortify::resetPasswordView(fn (Request $request) => Inertia::render('auth/ResetPassword', [
|
||||
|
|
@ -71,7 +72,9 @@ private function configureViews(): void
|
|||
'status' => $request->session()->get('status'),
|
||||
]));
|
||||
|
||||
Fortify::registerView(fn () => Inertia::render('auth/Register'));
|
||||
Fortify::registerView(fn (Request $request) => Inertia::render('auth/Register', [
|
||||
'email' => $request->query('email'),
|
||||
]));
|
||||
|
||||
Fortify::twoFactorChallengeView(fn () => Inertia::render('auth/TwoFactorChallenge'));
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,18 @@
|
|||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Self-Hosted Mode
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When enabled, the application runs in self-hosted mode which skips
|
||||
| payment/subscription requirements during onboarding.
|
||||
|
|
||||
*/
|
||||
|
||||
'self_hosted' => env('SELF_HOSTED', true),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Social Platforms
|
||||
|
|
|
|||
1
lang/php_en.json
Normal file
1
lang/php_en.json
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -7,29 +7,7 @@
|
|||
<tr>
|
||||
<td align="center" class="text-center text-zinc-600 text-xs p-6">
|
||||
<p class="m-0 mb-2">
|
||||
Enviado por <a href="https://clinyx.com.br" class="text-zinc-600">Clinyx</a> - Sistema para gestão de clínicas e consultórios
|
||||
</p>
|
||||
|
||||
<p class="m-0 cursor-default">
|
||||
<a href="https://www.linkedin.com/company/clinyx" target="_blank"
|
||||
class="text-zinc-600 [text-decoration:none] hover:![text-decoration:underline]">
|
||||
LinkedIn
|
||||
</a>
|
||||
•
|
||||
<a href="https://www.facebook.com/clinyx" target="_blank"
|
||||
class="text-zinc-600 [text-decoration:none] hover:![text-decoration:underline]">
|
||||
Facebook
|
||||
</a>
|
||||
•
|
||||
<a href="https://instagram.com/useclinyx" target="_blank"
|
||||
class="text-zinc-600 [text-decoration:none] hover:![text-decoration:underline]">
|
||||
Instagram
|
||||
</a>
|
||||
•
|
||||
<a href="https://youtube.com/@useclinyx" target="_blank"
|
||||
class="text-zinc-600 [text-decoration:none] hover:![text-decoration:underline]">
|
||||
YouTube
|
||||
</a>
|
||||
Open-source social media scheduling tool
|
||||
</p>
|
||||
|
||||
@if(isset($unsubscribe_url))
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<div class="my-12 sm:my-8 text-center">
|
||||
<a href="https://trypost.it" target="_blank">
|
||||
<img src="@{{ asset('/images/emails/trypost/logo-header.png') }}" width="160" alt="Trypost">
|
||||
<img src="@{{ asset('/images/emails/logo-header.png') }}" width="160" alt="Trypost">
|
||||
</a>
|
||||
</div>
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="pt-BR" xmlns:v="urn:schemas-microsoft-com:vml">
|
||||
<html lang="en" xmlns:v="urn:schemas-microsoft-com:vml">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
|
|
@ -39,15 +39,15 @@
|
|||
</head>
|
||||
|
||||
<body class="m-0 p-0 w-full [word-break:break-word] [-webkit-font-smoothing:antialiased] {{ page.bodyClass || '' }}">
|
||||
@if(isset($preheader))
|
||||
@if(isset($previewText))
|
||||
<div class="hidden">
|
||||
@{{ $preheader }}
|
||||
@{{ $previewText }}
|
||||
<each loop="item in Array.from(Array(150))"> ͏ </each>
|
||||
</div>
|
||||
@endif
|
||||
<div role="article" aria-roledescription="email" aria-label="@{{ $title }}" lang="pt-BR">
|
||||
<div role="article" aria-roledescription="email" aria-label="@{{ $title }}" lang="en">
|
||||
<yield />
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -8,30 +8,30 @@
|
|||
<tr>
|
||||
<td class="p-12 sm:px-6 text-base text-zinc-800 bg-white rounded shadow-sm">
|
||||
<p class="leading-6 mt-0">
|
||||
Olá @{{$user->name}},
|
||||
Hi @{{ $user->name }},
|
||||
</p>
|
||||
|
||||
<p class="leading-6">
|
||||
Por favor, confirme seu endereço de e-mail clicando no botão abaixo:
|
||||
Please confirm your email address by clicking the button below:
|
||||
</p>
|
||||
|
||||
<x-spacer height="24px" />
|
||||
|
||||
<div class="text-center">
|
||||
<x-button href="@{{ $url }}">
|
||||
Confirmar Endereço de E-mail →
|
||||
Verify Email Address →
|
||||
</x-button>
|
||||
</div>
|
||||
|
||||
<x-spacer height="24px" />
|
||||
|
||||
<p class="leading-6">
|
||||
Se você não criou esta conta, pode ignorar este e-mail com segurança.
|
||||
If you did not create an account, you can safely ignore this email.
|
||||
</p>
|
||||
<p class="leading-6 mb-0">
|
||||
Atenciosamente,
|
||||
Best regards,
|
||||
<br />
|
||||
Equipe Clinyx
|
||||
The TryPost Team
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
@ -41,4 +41,4 @@
|
|||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</x-main>
|
||||
</x-main>
|
||||
|
|
|
|||
|
|
@ -8,30 +8,30 @@
|
|||
<tr>
|
||||
<td class="p-12 sm:px-6 text-base text-zinc-800 bg-white rounded shadow-sm">
|
||||
<p class="leading-6 mt-0">
|
||||
Olá @{{$user->name}},
|
||||
Hi @{{ $user->name }},
|
||||
</p>
|
||||
|
||||
<p class="leading-6">
|
||||
Recebemos uma solicitação para redefinir a senha da sua conta. Clique no botão abaixo para criar uma nova senha:
|
||||
We received a request to reset your password. Click the button below to create a new password:
|
||||
</p>
|
||||
|
||||
<x-spacer height="24px" />
|
||||
|
||||
<div class="text-center">
|
||||
<x-button href="@{{ $url }}">
|
||||
Redefinir Senha →
|
||||
Reset Password →
|
||||
</x-button>
|
||||
</div>
|
||||
|
||||
<x-spacer height="24px" />
|
||||
|
||||
<p class="leading-6">
|
||||
Este link expira em 60 minutos. Se você não solicitou a redefinição de senha, pode ignorar este e-mail com segurança.
|
||||
This link expires in 60 minutes. If you didn't request a password reset, you can safely ignore this email.
|
||||
</p>
|
||||
<p class="leading-6 mb-0">
|
||||
Atenciosamente,
|
||||
Best regards,
|
||||
<br />
|
||||
Equipe Clinyx
|
||||
The TryPost Team
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,3 @@
|
|||
---
|
||||
bodyClass: bg-slate-50
|
||||
preheader: Convite para o sistema da Clinyx
|
||||
---
|
||||
<x-main>
|
||||
<div class="bg-zinc-50 sm:px-4 font-sans">
|
||||
<table align="center">
|
||||
|
|
@ -13,23 +9,30 @@
|
|||
<tr>
|
||||
<td class="p-12 sm:px-6 text-base text-zinc-700 bg-white rounded shadow-sm">
|
||||
<h1 class="m-0 mb-6 text-2xl sm:leading-8 text-black font-semibold">
|
||||
Olá 👋
|
||||
You've been invited!
|
||||
</h1>
|
||||
|
||||
<p class="m-0 leading-6">
|
||||
Você foi convidado para acessar o sistema da Clinyx.
|
||||
<br />
|
||||
<br />
|
||||
Para aceitar o convite e começar a utilizar a plataforma, clique no botão abaixo.
|
||||
<strong>@{{ $invite->inviter->name }}</strong> has invited you to collaborate on the <strong>@{{ $invite->workspace->name }}</strong> workspace.
|
||||
</p>
|
||||
|
||||
<p class="m-0 mt-4 leading-6">
|
||||
You've been invited as <strong>@{{ $invite->role->label() }}</strong>.
|
||||
</p>
|
||||
|
||||
<x-spacer height="24px" />
|
||||
|
||||
<div class="flex items-center justify-center">
|
||||
<x-button href="@{{ $url }}">
|
||||
Aceitar convite →
|
||||
Accept Invite →
|
||||
</x-button>
|
||||
</div>
|
||||
|
||||
<x-spacer height="24px" />
|
||||
|
||||
<p class="m-0 leading-6 text-zinc-500 text-sm">
|
||||
This invite expires in 7 days.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
|
@ -38,4 +41,4 @@ <h1 class="m-0 mb-6 text-2xl sm:leading-8 text-black font-semibold">
|
|||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</x-main>
|
||||
</x-main>
|
||||
BIN
public/images/emails/logo.png
Normal file
BIN
public/images/emails/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6 KiB |
|
|
@ -1,5 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import { Link } from '@inertiajs/vue3';
|
||||
import { Link, usePage } from '@inertiajs/vue3';
|
||||
import { computed } from 'vue';
|
||||
|
||||
import Heading from '@/components/Heading.vue';
|
||||
import { useActiveUrl } from '@/composables/useActiveUrl';
|
||||
|
|
@ -11,38 +12,47 @@ import { edit as editProfile } from '@/routes/profile';
|
|||
import { show } from '@/routes/two-factor';
|
||||
import { edit as editPassword } from '@/routes/user-password';
|
||||
import { settings as workspaceSettings } from '@/routes/workspace';
|
||||
import { type NavItem } from '@/types';
|
||||
import { type NavItem, type SharedData } from '@/types';
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{
|
||||
title: 'Profile',
|
||||
href: editProfile(),
|
||||
},
|
||||
{
|
||||
title: 'Password',
|
||||
href: editPassword(),
|
||||
},
|
||||
{
|
||||
title: 'Two-Factor Auth',
|
||||
href: show(),
|
||||
},
|
||||
{
|
||||
title: 'Appearance',
|
||||
href: editAppearance(),
|
||||
},
|
||||
{
|
||||
title: 'Workspace',
|
||||
href: workspaceSettings(),
|
||||
},
|
||||
{
|
||||
title: 'Members',
|
||||
href: members(),
|
||||
},
|
||||
{
|
||||
title: 'Billing',
|
||||
href: billing(),
|
||||
},
|
||||
];
|
||||
const page = usePage<SharedData>();
|
||||
|
||||
const navItems = computed<NavItem[]>(() => {
|
||||
const items: NavItem[] = [
|
||||
{
|
||||
title: 'Profile',
|
||||
href: editProfile(),
|
||||
},
|
||||
{
|
||||
title: 'Password',
|
||||
href: editPassword(),
|
||||
},
|
||||
{
|
||||
title: 'Two-Factor Auth',
|
||||
href: show(),
|
||||
},
|
||||
{
|
||||
title: 'Appearance',
|
||||
href: editAppearance(),
|
||||
},
|
||||
{
|
||||
title: 'Workspace',
|
||||
href: workspaceSettings(),
|
||||
},
|
||||
{
|
||||
title: 'Members',
|
||||
href: members(),
|
||||
},
|
||||
];
|
||||
|
||||
if (!page.props.selfHosted) {
|
||||
items.push({
|
||||
title: 'Billing',
|
||||
href: billing(),
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
});
|
||||
|
||||
const { urlIsActive } = useActiveUrl();
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ defineProps<{
|
|||
status?: string;
|
||||
canResetPassword: boolean;
|
||||
canRegister: boolean;
|
||||
email?: string | null;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
|
|
@ -52,6 +53,7 @@ defineProps<{
|
|||
:tabindex="1"
|
||||
autocomplete="email"
|
||||
placeholder="email@example.com"
|
||||
:default-value="email ?? ''"
|
||||
/>
|
||||
<InputError :message="errors.email" />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -10,6 +10,10 @@ import { Spinner } from '@/components/ui/spinner';
|
|||
import AuthBase from '@/layouts/AuthLayout.vue';
|
||||
import { login } from '@/routes';
|
||||
import { store } from '@/routes/register';
|
||||
|
||||
defineProps<{
|
||||
email?: string | null;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -51,6 +55,7 @@ import { store } from '@/routes/register';
|
|||
autocomplete="email"
|
||||
name="email"
|
||||
placeholder="email@example.com"
|
||||
:default-value="email ?? ''"
|
||||
/>
|
||||
<InputError :message="errors.email" />
|
||||
</div>
|
||||
|
|
|
|||
138
resources/js/pages/invites/Accept.vue
Normal file
138
resources/js/pages/invites/Accept.vue
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
<script setup lang="ts">
|
||||
import { Head, Link, router } from '@inertiajs/vue3';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { login, register } from '@/routes';
|
||||
import { ref } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
invite: {
|
||||
id: string;
|
||||
token: string;
|
||||
email: string;
|
||||
role: {
|
||||
value: string;
|
||||
label: string;
|
||||
};
|
||||
workspace: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
inviter: {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
};
|
||||
};
|
||||
isAuthenticated: boolean;
|
||||
userEmail?: string;
|
||||
}>();
|
||||
|
||||
const accepting = ref(false);
|
||||
|
||||
const acceptInvite = () => {
|
||||
accepting.value = true;
|
||||
router.post(`/invites/${props.invite.token}/accept`, {}, {
|
||||
onFinish: () => {
|
||||
accepting.value = false;
|
||||
},
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex min-h-svh flex-col items-center justify-center gap-6 bg-background p-6 md:p-10">
|
||||
<Head title="Accept Invite" />
|
||||
|
||||
<div class="w-full max-w-md">
|
||||
<div class="flex flex-col gap-8">
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<Link href="/" class="flex flex-col items-center gap-2 font-medium">
|
||||
<img src="/images/trypost/logo-light.png" alt="TryPost" class="dark:hidden h-8 w-auto" />
|
||||
<img src="/images/trypost/logo-dark.png" alt="TryPost" class="hidden dark:block h-8 w-auto" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader class="text-center">
|
||||
<CardTitle class="text-xl">You've been invited!</CardTitle>
|
||||
<CardDescription>
|
||||
<span class="font-medium text-foreground">{{ invite.inviter.name }}</span>
|
||||
has invited you to join the
|
||||
<span class="font-medium text-foreground">{{ invite.workspace.name }}</span>
|
||||
workspace.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-6">
|
||||
<div class="rounded-lg bg-muted p-4 space-y-2">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">Workspace</span>
|
||||
<span class="font-medium">{{ invite.workspace.name }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">Your role</span>
|
||||
<span class="font-medium">{{ invite.role.label }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">Invited by</span>
|
||||
<span class="font-medium">{{ invite.inviter.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Authenticated user -->
|
||||
<template v-if="isAuthenticated">
|
||||
<p class="text-sm text-muted-foreground text-center">
|
||||
You're logged in as <span class="font-medium text-foreground">{{ userEmail }}</span>
|
||||
</p>
|
||||
<Button
|
||||
@click="acceptInvite"
|
||||
class="w-full"
|
||||
size="lg"
|
||||
:disabled="accepting"
|
||||
>
|
||||
<Spinner v-if="accepting" class="mr-2" />
|
||||
Accept Invite
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<!-- Not authenticated -->
|
||||
<template v-else>
|
||||
<div class="space-y-3">
|
||||
<p class="text-sm text-muted-foreground text-center">
|
||||
To accept this invite, please log in or create an account.
|
||||
</p>
|
||||
<div class="grid gap-2">
|
||||
<Button
|
||||
as-child
|
||||
class="w-full"
|
||||
size="lg"
|
||||
>
|
||||
<Link :href="login({ query: { email: invite.email } })">
|
||||
Log in to accept
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
as-child
|
||||
variant="outline"
|
||||
class="w-full"
|
||||
size="lg"
|
||||
>
|
||||
<Link :href="register({ query: { email: invite.email } })">
|
||||
Create an account
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<p class="text-center text-xs text-muted-foreground">
|
||||
This invite was sent to <span class="font-medium">{{ invite.email }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import { Head, useForm, router } from '@inertiajs/vue3';
|
||||
import { UserPlus, Users, Mail, Clock, Trash2, Crown, User, Shield } from 'lucide-vue-next';
|
||||
import { UserPlus, Users, Mail, Trash2, Crown, User, Shield } from 'lucide-vue-next';
|
||||
|
||||
import HeadingSmall from '@/components/HeadingSmall.vue';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
|
@ -31,9 +31,8 @@ interface Member {
|
|||
interface Invite {
|
||||
id: string;
|
||||
email: string;
|
||||
role: { value: string; label: string };
|
||||
status: { value: string; label: string; color: string };
|
||||
expires_at: string;
|
||||
role: string;
|
||||
status: string;
|
||||
inviter: { name: string };
|
||||
}
|
||||
|
||||
|
|
@ -86,22 +85,29 @@ function handleRemoveMember(memberId: string) {
|
|||
}
|
||||
}
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
function getStatusLabel(status: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
pending: 'Pending',
|
||||
accepted: 'Accepted',
|
||||
};
|
||||
return labels[status] || status;
|
||||
}
|
||||
|
||||
function getStatusColor(color: string): string {
|
||||
function getStatusColor(status: string): string {
|
||||
const colors: Record<string, string> = {
|
||||
yellow: 'bg-yellow-100 text-yellow-800',
|
||||
green: 'bg-green-100 text-green-800',
|
||||
gray: 'bg-gray-100 text-gray-800',
|
||||
red: 'bg-red-100 text-red-800',
|
||||
pending: 'bg-yellow-100 text-yellow-800',
|
||||
accepted: 'bg-green-100 text-green-800',
|
||||
};
|
||||
return colors[color] || 'bg-gray-100 text-gray-800';
|
||||
return colors[status] || 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
|
||||
function getRoleLabel(role: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
owner: 'Owner',
|
||||
admin: 'Admin',
|
||||
member: 'Member',
|
||||
};
|
||||
return labels[role] || role;
|
||||
}
|
||||
|
||||
function getRoleIcon(role: string) {
|
||||
|
|
@ -178,7 +184,7 @@ function getRoleIcon(role: string) {
|
|||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2">
|
||||
<Clock class="h-5 w-5" />
|
||||
<Mail class="h-5 w-5" />
|
||||
Pending Invites
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
|
|
@ -199,18 +205,15 @@ function getRoleIcon(role: string) {
|
|||
<p class="font-medium truncate">{{ invite.email }}</p>
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<Badge variant="outline" class="text-xs">
|
||||
{{ invite.role.label }}
|
||||
{{ getRoleLabel(invite.role) }}
|
||||
</Badge>
|
||||
<Badge :class="getStatusColor(invite.status.color)" class="text-xs">
|
||||
{{ invite.status.label }}
|
||||
<Badge :class="getStatusColor(invite.status)" class="text-xs">
|
||||
{{ getStatusLabel(invite.status) }}
|
||||
</Badge>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground mt-1">
|
||||
Expires {{ formatDate(invite.expires_at) }}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
v-if="invite.status.value === 'pending'"
|
||||
v-if="invite.status === 'pending'"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@click="cancelInvite(invite.id)"
|
||||
|
|
|
|||
11
resources/js/types/index.d.ts
vendored
11
resources/js/types/index.d.ts
vendored
|
|
@ -17,14 +17,17 @@ export interface NavItem {
|
|||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export type AppPageProps<
|
||||
T extends Record<string, unknown> = Record<string, unknown>,
|
||||
> = T & {
|
||||
export interface SharedData {
|
||||
name: string;
|
||||
auth: Auth;
|
||||
sidebarOpen: boolean;
|
||||
selfHosted: boolean;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export type AppPageProps<
|
||||
T extends Record<string, unknown> = Record<string, unknown>,
|
||||
> = T & SharedData;
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
|
|
|
|||
116
resources/views/mail/email-verification.blade.php
Normal file
116
resources/views/mail/email-verification.blade.php
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en" xmlns:v="urn:schemas-microsoft-com:vml">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="x-apple-disable-message-reformatting">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="format-detection" content="telephone=no, date=no, address=no, email=no, url=no">
|
||||
<meta name="color-scheme" content="light">
|
||||
<meta name="supported-color-schemes" content="light">
|
||||
<!--[if mso]>
|
||||
<noscript>
|
||||
<xml>
|
||||
<o:OfficeDocumentSettings xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||
</o:OfficeDocumentSettings>
|
||||
</xml>
|
||||
</noscript>
|
||||
<style>
|
||||
td,th,div,p,a,h1,h2,h3,h4,h5,h6 {font-family: "Segoe UI", sans-serif; mso-line-height-rule: exactly;}
|
||||
</style>
|
||||
<![endif]-->
|
||||
@if(isset($title))
|
||||
<title>{{ $title }}</title>
|
||||
@endif
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap" rel="stylesheet" media="screen">
|
||||
<style>
|
||||
.hover-i-text-decoration-underline:hover {
|
||||
text-decoration: underline !important
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.sm-my-8 {
|
||||
margin-top: 32px !important;
|
||||
margin-bottom: 32px !important
|
||||
}
|
||||
.sm-px-4 {
|
||||
padding-left: 16px !important;
|
||||
padding-right: 16px !important
|
||||
}
|
||||
.sm-px-6 {
|
||||
padding-left: 24px !important;
|
||||
padding-right: 24px !important
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body style="margin: 0; width: 100%; padding: 0; -webkit-font-smoothing: antialiased; word-break: break-word">
|
||||
@if(isset($previewText))
|
||||
<div style="display: none">
|
||||
{{ $previewText }}
|
||||
 ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏
|
||||
</div>
|
||||
@endif
|
||||
<div role="article" aria-roledescription="email" aria-label="{{ $title }}" lang="en">
|
||||
<div class="sm-px-4" style="background-color: #fafafa; font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif">
|
||||
<table align="center" cellpadding="0" cellspacing="0" role="none">
|
||||
<tr>
|
||||
<td style="width: 552px; max-width: 100%">
|
||||
<div class="sm-my-8" style="margin-top: 48px; margin-bottom: 48px; text-align: center">
|
||||
<a href="https://trypost.it" target="_blank">
|
||||
<img src="{{ asset('/images/emails/logo-header.png') }}" width="160" alt="Trypost" style="max-width: 100%; vertical-align: middle">
|
||||
</a>
|
||||
</div>
|
||||
<table style="width: 100%" cellpadding="0" cellspacing="0" role="none">
|
||||
<tr>
|
||||
<td class="sm-px-6" style="border-radius: 4px; background-color: #fffffe; padding: 48px; font-size: 16px; color: #27272a; box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05)">
|
||||
<p style="margin-top: 0; line-height: 24px">
|
||||
Hi {{ $user->name }},
|
||||
</p>
|
||||
<p style="line-height: 24px">
|
||||
Please confirm your email address by clicking the button below:
|
||||
</p>
|
||||
<div role="separator" style="line-height: 24px">‍</div>
|
||||
<div style="text-align: center">
|
||||
<div>
|
||||
<a href="{{ $url }}" style="display: inline-block; text-decoration: none; padding: 16px 24px; font-size: 16px; line-height: 1; border-radius: 8px; background-color: #262626; color: #ffffff">
|
||||
<!--[if mso]><i style="mso-font-width: 150%; mso-text-raise: 31px" hidden> </i><![endif]-->
|
||||
<span style="mso-text-raise: 16px">Verify Email Address →</span>
|
||||
<!--[if mso]><i hidden style="mso-font-width: 150%"> ​</i><![endif]-->
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div role="separator" style="line-height: 24px">‍</div>
|
||||
<p style="line-height: 24px">
|
||||
If you did not create an account, you can safely ignore this email.
|
||||
</p>
|
||||
<p style="margin-bottom: 0; line-height: 24px">
|
||||
Best regards,
|
||||
<br>
|
||||
The TryPost Team
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="padding: 24px; text-align: center; font-size: 12px; color: #52525b">
|
||||
<p style="margin: 0 0 8px">
|
||||
Open-source social media scheduling tool
|
||||
</p>
|
||||
@if(isset($unsubscribe_url))
|
||||
<p style="margin: 8px 0 0">
|
||||
<a href="{{ unsubscribe_url }}" target="_blank" class="hover-i-text-decoration-underline" style="color: #52525b; text-decoration: none">
|
||||
Unsubscribe
|
||||
</a>
|
||||
</p>
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
116
resources/views/mail/password-reset.blade.php
Normal file
116
resources/views/mail/password-reset.blade.php
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en" xmlns:v="urn:schemas-microsoft-com:vml">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="x-apple-disable-message-reformatting">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="format-detection" content="telephone=no, date=no, address=no, email=no, url=no">
|
||||
<meta name="color-scheme" content="light">
|
||||
<meta name="supported-color-schemes" content="light">
|
||||
<!--[if mso]>
|
||||
<noscript>
|
||||
<xml>
|
||||
<o:OfficeDocumentSettings xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||
</o:OfficeDocumentSettings>
|
||||
</xml>
|
||||
</noscript>
|
||||
<style>
|
||||
td,th,div,p,a,h1,h2,h3,h4,h5,h6 {font-family: "Segoe UI", sans-serif; mso-line-height-rule: exactly;}
|
||||
</style>
|
||||
<![endif]-->
|
||||
@if(isset($title))
|
||||
<title>{{ $title }}</title>
|
||||
@endif
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap" rel="stylesheet" media="screen">
|
||||
<style>
|
||||
.hover-i-text-decoration-underline:hover {
|
||||
text-decoration: underline !important
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.sm-my-8 {
|
||||
margin-top: 32px !important;
|
||||
margin-bottom: 32px !important
|
||||
}
|
||||
.sm-px-4 {
|
||||
padding-left: 16px !important;
|
||||
padding-right: 16px !important
|
||||
}
|
||||
.sm-px-6 {
|
||||
padding-left: 24px !important;
|
||||
padding-right: 24px !important
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body style="margin: 0; width: 100%; padding: 0; -webkit-font-smoothing: antialiased; word-break: break-word">
|
||||
@if(isset($previewText))
|
||||
<div style="display: none">
|
||||
{{ $previewText }}
|
||||
 ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏
|
||||
</div>
|
||||
@endif
|
||||
<div role="article" aria-roledescription="email" aria-label="{{ $title }}" lang="en">
|
||||
<div class="sm-px-4" style="background-color: #fafafa; font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif">
|
||||
<table align="center" cellpadding="0" cellspacing="0" role="none">
|
||||
<tr>
|
||||
<td style="width: 552px; max-width: 100%">
|
||||
<div class="sm-my-8" style="margin-top: 48px; margin-bottom: 48px; text-align: center">
|
||||
<a href="https://trypost.it" target="_blank">
|
||||
<img src="{{ asset('/images/emails/logo-header.png') }}" width="160" alt="Trypost" style="max-width: 100%; vertical-align: middle">
|
||||
</a>
|
||||
</div>
|
||||
<table style="width: 100%" cellpadding="0" cellspacing="0" role="none">
|
||||
<tr>
|
||||
<td class="sm-px-6" style="border-radius: 4px; background-color: #fffffe; padding: 48px; font-size: 16px; color: #27272a; box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05)">
|
||||
<p style="margin-top: 0; line-height: 24px">
|
||||
Hi {{ $user->name }},
|
||||
</p>
|
||||
<p style="line-height: 24px">
|
||||
We received a request to reset your password. Click the button below to create a new password:
|
||||
</p>
|
||||
<div role="separator" style="line-height: 24px">‍</div>
|
||||
<div style="text-align: center">
|
||||
<div>
|
||||
<a href="{{ $url }}" style="display: inline-block; text-decoration: none; padding: 16px 24px; font-size: 16px; line-height: 1; border-radius: 8px; background-color: #262626; color: #ffffff">
|
||||
<!--[if mso]><i style="mso-font-width: 150%; mso-text-raise: 31px" hidden> </i><![endif]-->
|
||||
<span style="mso-text-raise: 16px">Reset Password →</span>
|
||||
<!--[if mso]><i hidden style="mso-font-width: 150%"> ​</i><![endif]-->
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div role="separator" style="line-height: 24px">‍</div>
|
||||
<p style="line-height: 24px">
|
||||
This link expires in 60 minutes. If you didn't request a password reset, you can safely ignore this email.
|
||||
</p>
|
||||
<p style="margin-bottom: 0; line-height: 24px">
|
||||
Best regards,
|
||||
<br>
|
||||
The TryPost Team
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="padding: 24px; text-align: center; font-size: 12px; color: #52525b">
|
||||
<p style="margin: 0 0 8px">
|
||||
Open-source social media scheduling tool
|
||||
</p>
|
||||
@if(isset($unsubscribe_url))
|
||||
<p style="margin: 8px 0 0">
|
||||
<a href="{{ unsubscribe_url }}" target="_blank" class="hover-i-text-decoration-underline" style="color: #52525b; text-decoration: none">
|
||||
Unsubscribe
|
||||
</a>
|
||||
</p>
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
117
resources/views/mail/workspace-invite.blade.php
Normal file
117
resources/views/mail/workspace-invite.blade.php
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en" xmlns:v="urn:schemas-microsoft-com:vml">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="x-apple-disable-message-reformatting">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="format-detection" content="telephone=no, date=no, address=no, email=no, url=no">
|
||||
<meta name="color-scheme" content="light">
|
||||
<meta name="supported-color-schemes" content="light">
|
||||
<!--[if mso]>
|
||||
<noscript>
|
||||
<xml>
|
||||
<o:OfficeDocumentSettings xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||
</o:OfficeDocumentSettings>
|
||||
</xml>
|
||||
</noscript>
|
||||
<style>
|
||||
td,th,div,p,a,h1,h2,h3,h4,h5,h6 {font-family: "Segoe UI", sans-serif; mso-line-height-rule: exactly;}
|
||||
</style>
|
||||
<![endif]-->
|
||||
@if(isset($title))
|
||||
<title>{{ $title }}</title>
|
||||
@endif
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap" rel="stylesheet" media="screen">
|
||||
<style>
|
||||
.hover-i-text-decoration-underline:hover {
|
||||
text-decoration: underline !important
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.sm-my-8 {
|
||||
margin-top: 32px !important;
|
||||
margin-bottom: 32px !important
|
||||
}
|
||||
.sm-px-4 {
|
||||
padding-left: 16px !important;
|
||||
padding-right: 16px !important
|
||||
}
|
||||
.sm-px-6 {
|
||||
padding-left: 24px !important;
|
||||
padding-right: 24px !important
|
||||
}
|
||||
.sm-leading-8 {
|
||||
line-height: 32px !important
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body style="margin: 0; width: 100%; padding: 0; -webkit-font-smoothing: antialiased; word-break: break-word">
|
||||
@if(isset($previewText))
|
||||
<div style="display: none">
|
||||
{{ $previewText }}
|
||||
 ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏
|
||||
</div>
|
||||
@endif
|
||||
<div role="article" aria-roledescription="email" aria-label="{{ $title }}" lang="en">
|
||||
<div class="sm-px-4" style="background-color: #fafafa; font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif">
|
||||
<table align="center" cellpadding="0" cellspacing="0" role="none">
|
||||
<tr>
|
||||
<td style="width: 552px; max-width: 100%">
|
||||
<div class="sm-my-8" style="margin-top: 48px; margin-bottom: 48px; text-align: center">
|
||||
<a href="https://trypost.it" target="_blank">
|
||||
<img src="{{ asset('/images/emails/logo-header.png') }}" width="160" alt="Trypost" style="max-width: 100%; vertical-align: middle">
|
||||
</a>
|
||||
</div>
|
||||
<table style="width: 100%" cellpadding="0" cellspacing="0" role="none">
|
||||
<tr>
|
||||
<td class="sm-px-6" style="border-radius: 4px; background-color: #fffffe; padding: 48px; font-size: 16px; color: #3f3f46; box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05)">
|
||||
<h1 class="sm-leading-8" style="margin: 0 0 24px; font-size: 24px; font-weight: 600; color: #000001">
|
||||
You've been invited!
|
||||
</h1>
|
||||
<p style="margin: 0; line-height: 24px">
|
||||
<strong>{{ $invite->inviter->name }}</strong> has invited you to collaborate on the <strong>{{ $invite->workspace->name }}</strong> workspace.
|
||||
</p>
|
||||
<p style="margin: 16px 0 0; line-height: 24px">
|
||||
You've been invited as <strong>{{ $invite->role->label() }}</strong>.
|
||||
</p>
|
||||
<div role="separator" style="line-height: 24px">‍</div>
|
||||
<div style="display: flex; align-items: center; justify-content: center">
|
||||
<div>
|
||||
<a href="{{ $url }}" style="display: inline-block; text-decoration: none; padding: 16px 24px; font-size: 16px; line-height: 1; border-radius: 8px; background-color: #262626; color: #ffffff">
|
||||
<!--[if mso]><i style="mso-font-width: 150%; mso-text-raise: 31px" hidden> </i><![endif]-->
|
||||
<span style="mso-text-raise: 16px">Accept Invite →</span>
|
||||
<!--[if mso]><i hidden style="mso-font-width: 150%"> ​</i><![endif]-->
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div role="separator" style="line-height: 24px">‍</div>
|
||||
<p style="margin: 0; font-size: 14px; line-height: 24px; color: #71717a">
|
||||
This invite expires in 7 days.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="padding: 24px; text-align: center; font-size: 12px; color: #52525b">
|
||||
<p style="margin: 0 0 8px">
|
||||
Open-source social media scheduling tool
|
||||
</p>
|
||||
@if(isset($unsubscribe_url))
|
||||
<p style="margin: 8px 0 0">
|
||||
<a href="{{ unsubscribe_url }}" target="_blank" class="hover-i-text-decoration-underline" style="color: #52525b; text-decoration: none">
|
||||
Unsubscribe
|
||||
</a>
|
||||
</p>
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -158,8 +158,10 @@
|
|||
Route::get('billing/portal', [BillingController::class, 'portal'])->name('billing.portal');
|
||||
});
|
||||
|
||||
// Accept invite (accessible with or without auth)
|
||||
Route::get('invites/{token}/accept', [WorkspaceInviteController::class, 'accept'])
|
||||
// Invite routes (accessible with or without auth)
|
||||
Route::get('invites/{token}/accept', [WorkspaceInviteController::class, 'show'])
|
||||
->name('invites.show');
|
||||
Route::post('invites/{token}/accept', [WorkspaceInviteController::class, 'accept'])
|
||||
->name('invites.accept');
|
||||
|
||||
require __DIR__.'/settings.php';
|
||||
|
|
|
|||
Loading…
Reference in a new issue