Skip to content

Commit

Permalink
[SOA-59] Social login with ally (#60)
Browse files Browse the repository at this point in the history
* chore πŸ—οΈ : add and configure ally

* feat 🎸 (be): add routes, controller and methods

* styles πŸ’… : add generic social buttons and divider components

* feat 🎸 (be): method completion

* fix βœ… : minor corrections

* chore πŸ—οΈ : update README.md

* fix βœ… : config corrections based on env

* styles πŸ’… : improve loading/processing feedback on social buttons

* fix βœ… : hosteddomain env tweak

* chore πŸ—οΈ (lint): review lint checking commands

* refactor ✨ : clean up dynamic get driver logic

* styles πŸ’… : add enabled flag to social login auth buttons

* refactor ✨ (types): utils TW types for prop colors
  • Loading branch information
mariadriana-deemaze authored Dec 11, 2024
1 parent c9e00a6 commit 523d4ed
Show file tree
Hide file tree
Showing 20 changed files with 259 additions and 31 deletions.
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,7 @@ VITE_APP_NAME=SocialAdonis
SMTP_HOST=mailhog
SMTP_PORT=1025
PRODUCTION_URL=https://social-adonis.fly.dev
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
21 changes: 3 additions & 18 deletions .husky/pre-push
Original file line number Diff line number Diff line change
@@ -1,22 +1,7 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
echo 'πŸ—οΈπŸ‘· Styling, formatting and typechecking your project before committing'

echo 'πŸ—οΈπŸ‘· Styling, testing and building your project before committing'

yarn format ||
(
echo 'πŸ˜€πŸ€πŸ‘‹πŸ˜€ Prettier Check Failed. Run yarn format, add changes and try to commit again.';
false;
)

yarn lint ||
(
echo 'πŸ˜€πŸ€πŸ‘‹πŸ˜€ ESLint Check Failed. Make the required changes listed above and try to commit again.'
false;
)

yarn typecheck ||
yarn clean ||
(
echo 'πŸ˜€πŸ€πŸ‘‹πŸ˜€ TS Check Failed. Make the required changes listed above and try to commit again.'
echo 'πŸ˜€πŸ€πŸ‘‹πŸ˜€ Pre-checks Failed. Make the required changes listed above and try to commit again.'
false;
)
18 changes: 14 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,20 @@ $ yarn dev
```

### Implemented main features

- Basic authentication;
- User feed;
- User posting CRUD;
- Front office:
- Authentication:
- Session based;
- OAuth with Google and Github;
- Global feed;
- User actions:
- Content creation;
- Content reporting;
- Mentions;
- Follows;
- Account deletion;
- User platform notifications;
- Admin area:
- User reports and action;

### Future roadmap

Expand Down
1 change: 1 addition & 0 deletions adonisrc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export default defineConfig({
() => import('@izzyjs/route/izzy_provider'),
() => import('@osenco/adonisjs-notifications/notification_provider'),
() => import('@adonisjs/mail/mail_provider'),
() => import('@adonisjs/ally/ally_provider'),
],

/*
Expand Down
48 changes: 48 additions & 0 deletions app/controllers/o_auth_controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import User from '#models/user'
import AuthService from '#services/auth_service'
import { randomPasswordGenerator } from '#utils/index'
import { GithubDriver } from '@adonisjs/ally/drivers/github'
import { GoogleDriver } from '@adonisjs/ally/drivers/google'
import { inject } from '@adonisjs/core'
import type { HttpContext } from '@adonisjs/core/http'
import hash from '@adonisjs/core/services/hash'

@inject()
export default class OAuthController {
constructor(private readonly authService: AuthService) {}

async redirect(ctx: HttpContext): Promise<void> {
const driver = this.getDriver(ctx)
if (!driver) return ctx.response.notFound()
return driver.redirect()
}

async callback(ctx: HttpContext): Promise<void> {
const driver = this.getDriver(ctx)
if (!driver) return ctx.response.notFound()

const details = await driver.user()

let user = await User.firstOrCreate(
{
email: details.email,
},
{
name: String(details.name).split(' ')[0],
surname: String(details.name).split(' ')[1] ?? '',
email: details.email,
password: await hash.make(randomPasswordGenerator()),
}
)

await this.authService.authenticate(ctx, user)
}

private getDriver({ ally, params }: HttpContext): GithubDriver | GoogleDriver | null {
const driver: GithubDriver | GoogleDriver = ally.use(params.provider)
if (!(driver instanceof GithubDriver) && !(driver instanceof GoogleDriver)) {
return null
}
return driver
}
}
5 changes: 1 addition & 4 deletions app/services/auth_service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,10 +108,7 @@ export default class AuthService {
return response.redirect().toPath('/')
}

private async authenticate(
{ auth, session, request, response }: HttpContext,
user: User
): Promise<void> {
async authenticate({ auth, session, request, response }: HttpContext, user: User): Promise<void> {
await auth.use('web').login(user)

await Session.create({
Expand Down
14 changes: 14 additions & 0 deletions app/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,20 @@ export function sanitizePostContent(content: string): string {
return content.replace(/[&<>]/g, (m) => map[m])
}

/**
* Generates a random password. Mainly userd for the OAuth account generation.
*/
export function randomPasswordGenerator(): string {
var chars = '0123456789abcdefghijklmnopqrstuvwxyz!@#$%^&*()ABCDEFGHIJKLMNOPQRSTUVWXYZ'
var passwordLength = 12
var password = ''
for (var i = 0; i <= passwordLength; i++) {
var randomNumber = Math.floor(Math.random() * chars.length)
password += chars.substring(randomNumber, randomNumber + 1)
}
return password
}

export const REGEX = {
ALPHA_STRING: /^[A-z]+$/,
ALPHANUMERIC_STRING: /^[A-z0-9]+$/,
Expand Down
34 changes: 34 additions & 0 deletions config/ally.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import env from '#start/env'
import { defineConfig, services } from '@adonisjs/ally'

const allyConfig = defineConfig({
google: services.google({
clientId: env.get('GOOGLE_CLIENT_ID'),
clientSecret: env.get('GOOGLE_CLIENT_SECRET'),
callbackUrl:
(env.get('NODE_ENV') === 'development'
? 'http://localhost:3000/'
: env.get('PRODUCTION_URL')) + 'auth/google/callback/',
prompt: 'select_account',
hostedDomain:
env.get('NODE_ENV') === 'development' ? 'http://localhost:3000/' : env.get('PRODUCTION_URL'),
display: 'page',
scopes: ['userinfo.email'],
}),
github: services.github({
clientId: env.get('GITHUB_CLIENT_ID')!,
clientSecret: env.get('GITHUB_CLIENT_SECRET')!,
callbackUrl:
(env.get('NODE_ENV') === 'development'
? 'http://localhost:3000/'
: env.get('PRODUCTION_URL')) + 'auth/github/callback/',
scopes: ['user'],
allowSignup: true,
}),
})

export default allyConfig

declare module '@adonisjs/ally/types' {
interface SocialProviders extends InferSocialProviders<typeof allyConfig> {}
}
2 changes: 1 addition & 1 deletion inertia/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export default function Layout({ children }: { children: ReactNode }) {
<>
<link rel="icon" type="image/svg+xml" href={favicon} />
<UserNavBar user={user} />
<div className="container flex justify-start pt-20">{children}</div>
<div className="container mt-20 flex justify-start">{children}</div>
<Toaster />
</>
)
Expand Down
19 changes: 19 additions & 0 deletions inertia/components/generic/divider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { cn } from '@/lib/utils'
import { TWColor } from '@/types/utils'

interface DividerProps {
text: string
color?: TWColor
}

function Divider({ text, color = 'gray-500' }: DividerProps) {
return (
<div className={cn('flex flex-row items-center gap-4', `color-${color}`)}>
<hr className="flex flex-grow" />
<small className={cn('uppercase', `text-${color}`)}>{text}</small>
<hr className="flex flex-grow" />
</div>
)
}

export default Divider
63 changes: 63 additions & 0 deletions inertia/components/generic/social_buttons.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import Divider from '@/components/generic/divider'
import { Button } from '@/components/ui/button'
import { route } from '@izzyjs/route/client'
import { Chrome, Github } from 'lucide-react'
import { useState } from 'react'

interface SocialButtonsProps {
disabled?: boolean
}

const SOCIALS = [
{
provider: 'Google',
Icon: Chrome,
enabled: false,
},
{
provider: 'GitHub',
Icon: Github,
enabled: true,
},
] as const

function SocialButtons({ disabled = false }: SocialButtonsProps) {
const [processing, setProcessing] = useState<'Google' | 'GitHub' | null>(null)
return (
<>
<Divider text="with socials" color="gray-500" />
{SOCIALS.map(({ provider, Icon, enabled }) => {
const providerLC = provider.toLowerCase()
return (
<Button
key={`auth-${providerLC}`}
type="button"
className="w-full"
variant="outline"
disabled={!enabled || processing === provider || disabled}
loading={processing === provider}
>
<a
className="flex flex-row items-center gap-2"
href={
enabled
? route('auth.redirect', {
params: {
provider: providerLC,
},
}).path
: '#'
}
onClick={() => setProcessing(provider)}
>
<Icon size={16} />
{provider}
</a>
</Button>
)
})}
</>
)
}

export default SocialButtons
4 changes: 2 additions & 2 deletions inertia/components/ui/button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,8 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
disabled={loading}
{...props}
>
{loading && <Loader2 className="mr-2 h-5 w-5 animate-spin text-muted" />}
<Slottable>{loading ? 'Submitting...' : children}</Slottable>
{loading && <Loader2 className="mr-2 h-5 w-5 animate-spin" />}
<Slottable>{children}</Slottable>
</Comp>
)
}
Expand Down
6 changes: 4 additions & 2 deletions inertia/components/ui/input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement>
error?: string
}

const iconSize = 16

const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ LeftSlot, RightSlot, className, type, error, ...props }, ref) => {
const [controlledType, setControlledType] = React.useState(() => type)
Expand All @@ -28,8 +30,8 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(

const PasswordSlot = () => (
<>
{type === 'password' && controlledType === 'password' && <EyeOff />}
{type === 'password' && controlledType === 'text' && <Eye />}
{type === 'password' && controlledType === 'password' && <EyeOff size={iconSize} />}
{type === 'password' && controlledType === 'text' && <Eye size={iconSize} />}
</>
)

Expand Down
4 changes: 4 additions & 0 deletions inertia/pages/sign_in.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { route } from '@izzyjs/route/client'
import HeadOG from '@/components/generic/head_og'
import { InferPageProps, SharedProps } from '@adonisjs/inertia/types'
import AuthController from '#controllers/auth_controller'
import SocialButtons from '@/components/generic/social_buttons'

export default function SignIn({
notification,
Expand Down Expand Up @@ -76,9 +77,12 @@ export default function SignIn({
RightSlot
/>
</div>

<Button type="submit" className="w-full" disabled={processing}>
Login
</Button>

<SocialButtons disabled={processing} />
</div>
<div className="mt-4 text-center text-sm leading-7">
<p>
Expand Down
3 changes: 3 additions & 0 deletions inertia/pages/sign_up.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { Label } from '@/components/ui/label'
import { useToast } from '@/components/ui/use_toast'
import AdonisLogo from '@/components/svg/logo'
import { route } from '@izzyjs/route/client'
import SocialButtons from '@/components/generic/social_buttons'

export default function SignUp() {
const { toast } = useToast()
Expand Down Expand Up @@ -94,6 +95,8 @@ export default function SignUp() {
<Button type="submit" className="w-full" disabled={processing}>
Create an account
</Button>

<SocialButtons disabled={processing} />
</div>
<div className="mt-4 text-center text-sm">
Already have an account?{' '}
Expand Down
6 changes: 6 additions & 0 deletions inertia/types/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import type { DefaultColors } from '../../node_modules/tailwindcss/types/generated/colors'

type TWColorDefaults = 'inherit' | 'current' | 'transparent' | 'black' | 'white'
type TWColorKey = keyof Omit<DefaultColors, TWColorDefaults>
type TWColorTone = keyof DefaultColors[TWColorKey]
export type TWColor = `${TWColorKey}-${TWColorTone}` | TWColorDefaults
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"test-coverage": "c8 node ace test --check-coverage",
"lint": "eslint .",
"format": "prettier --write .",
"clean": "yarn format && yarn lint && yarn typecheck",
"typecheck": "tsc --noEmit",
"prepare": "husky"
},
Expand Down Expand Up @@ -68,6 +69,7 @@
"vite": "^5.3.1"
},
"dependencies": {
"@adonisjs/ally": "^5.0.2",
"@adonisjs/auth": "^9.2.3",
"@adonisjs/bouncer": "^3.1.3",
"@adonisjs/core": "^6.12.1",
Expand Down
10 changes: 10 additions & 0 deletions start/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,4 +66,14 @@ export default await Env.create(new URL('../', import.meta.url), {
LOCAL_SMTP_HOST: Env.schema.string(),
LOCAL_SMTP_PORT: Env.schema.string(),
FROM_MAIL: Env.schema.string(),

/*
|----------------------------------------------------------
| Variables for configuring ally package
|----------------------------------------------------------
*/
GOOGLE_CLIENT_ID: Env.schema.string(),
GOOGLE_CLIENT_SECRET: Env.schema.string(),
GITHUB_CLIENT_ID: Env.schema.string(),
GITHUB_CLIENT_SECRET: Env.schema.string(),
})
Loading

0 comments on commit 523d4ed

Please sign in to comment.